link-stack/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts

257 lines
8.4 KiB
TypeScript
Raw Normal View History

2025-10-15 16:08:53 +02:00
import { createLogger } from "@link-stack/logger";
2025-10-26 15:39:55 +01:00
import { Zammad, getUser } from "../../lib/zammad.js";
2025-11-10 14:55:22 +01:00
import {
loadFieldMapping,
getFieldValue,
getNestedFieldValue,
formatFieldValue,
buildTicketTitle,
getZammadFieldValues,
type FieldMappingConfig,
} from "../../lib/formstack-field-mapping.js";
2025-10-15 16:08:53 +02:00
const logger = createLogger('create-ticket-from-form');
export interface CreateTicketFromFormOptions {
formData: any;
receivedAt: string;
}
const createTicketFromFormTask = async (
options: CreateTicketFromFormOptions,
): Promise<void> => {
const { formData, receivedAt } = options;
2025-11-10 14:55:22 +01:00
// 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);
2025-10-15 16:08:53 +02:00
logger.info({
2025-11-10 14:55:22 +01:00
formId,
uniqueId,
2025-10-15 16:08:53 +02:00
receivedAt,
2025-11-10 14:55:22 +01:00
fieldCount: Object.keys(formData).length
2025-10-15 16:08:53 +02:00
}, 'Processing Formstack form submission');
2025-11-10 14:55:22 +01:00
// 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) || ''
: '';
2025-10-27 21:02:19 +01:00
const fullName = (firstName && lastName)
? `${firstName} ${lastName}`.trim()
: firstName || lastName || 'Unknown';
2025-10-15 16:08:53 +02:00
2025-11-10 14:55:22 +01:00
// 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');
2025-10-15 16:08:53 +02:00
}
2025-11-10 14:55:22 +01:00
// 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
2025-10-27 21:02:19 +01:00
const formatAllFields = (data: any): string => {
let html = '';
2025-11-10 14:55:22 +01:00
// Add formatted name field first if we have it
if (fullName && fullName !== 'Unknown') {
html += `<strong>Name:</strong><br>${fullName}<br>`;
}
2025-10-27 21:02:19 +01:00
for (const [key, value] of Object.entries(data)) {
2025-11-10 14:55:22 +01:00
// 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;
2025-10-27 21:02:19 +01:00
if (value === null || value === undefined || value === '') continue;
2025-10-26 15:39:55 +01:00
2025-10-27 21:02:19 +01:00
const displayValue = Array.isArray(value) ? value.join(', ') :
typeof value === 'object' ? JSON.stringify(value) : value;
html += `<strong>${key}:</strong><br>${displayValue}<br>`;
}
return html;
};
2025-10-15 16:08:53 +02:00
2025-10-27 21:02:19 +01:00
const body = formatAllFields(formData);
2025-10-15 16:08:53 +02:00
// 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 {
2025-11-10 14:55:22 +01:00
// Look up the configured article type
let articleTypeId: number | undefined;
2025-10-27 21:02:19 +01:00
try {
const articleTypes = await zammad.get('ticket_article_types');
2025-11-10 14:55:22 +01:00
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');
2025-10-27 21:02:19 +01:00
} else {
2025-11-10 14:55:22 +01:00
logger.warn({ typeName: mapping.ticket.defaultArticleType }, 'Configured article type not found, ticket will use default type');
2025-10-27 21:02:19 +01:00
}
} catch (error: any) {
2025-11-10 14:55:22 +01:00
logger.warn({ error: error.message }, 'Failed to look up article type');
2025-10-27 21:02:19 +01:00
}
2025-11-10 14:55:22 +01:00
// Get or create user
// Try to find existing user by: signalAccount -> phone -> email
2025-10-15 16:08:53 +02:00
let customer;
2025-11-10 14:55:22 +01:00
// 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);
2025-10-26 15:39:55 +01:00
if (customer) {
logger.info({ customerId: customer.id, method: 'phone' }, 'Found existing user by phone');
}
}
2025-11-10 14:55:22 +01:00
// 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');
2025-10-26 15:39:55 +01:00
}
}
if (!customer) {
2025-11-10 14:55:22 +01:00
// Create new user
2025-10-26 15:39:55 +01:00
logger.info('Creating new user from form submission');
2025-11-10 14:55:22 +01:00
// Build user data with whatever contact info we have
const userData: any = {
2025-10-27 21:02:19 +01:00
firstname: firstName,
lastname: lastName,
roles: ['Customer'],
2025-11-10 14:55:22 +01:00
};
// 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);
2025-10-15 16:08:53 +02:00
}
2025-10-26 15:39:55 +01:00
logger.info({
customerId: customer.id,
2025-11-10 14:55:22 +01:00
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,
2025-10-27 21:02:19 +01:00
};
2025-10-26 15:39:55 +01:00
2025-11-10 14:55:22 +01:00
if (articleTypeId) {
articleData.type_id = articleTypeId;
}
const ticketData = {
2025-10-15 16:08:53 +02:00
title,
2025-11-10 14:55:22 +01:00
group_id: targetGroup.id,
2025-10-15 16:08:53 +02:00
customer_id: customer.id,
2025-11-10 14:55:22 +01:00
article: articleData,
...customFields,
2025-10-27 21:02:19 +01:00
};
2025-11-10 14:55:22 +01:00
logger.info({
title,
groupId: targetGroup.id,
customerId: customer.id,
hasArticleType: !!articleTypeId,
customFieldCount: Object.keys(customFields).length,
}, 'Creating ticket');
2025-10-27 21:02:19 +01:00
2025-11-10 14:55:22 +01:00
const ticket = await zammad.ticket.create(ticketData);
2025-10-15 16:08:53 +02:00
logger.info({
ticketId: ticket.id,
2025-11-10 14:55:22 +01:00
ticketNumber: ticket.id,
title,
}, 'Successfully created ticket from Formstack submission');
2025-10-15 16:08:53 +02:00
} catch (error: any) {
logger.error({
error: error.message,
stack: error.stack,
2025-11-10 14:55:22 +01:00
formId,
uniqueId,
}, 'Failed to create ticket from Formstack submission');
2025-10-15 16:08:53 +02:00
throw error;
}
};
export default createTicketFromFormTask;