Signal group and Formstack fixes
This commit is contained in:
parent
00d1fe5eef
commit
0e8c9be247
16 changed files with 692 additions and 142 deletions
|
|
@ -10,7 +10,7 @@ import {
|
|||
type FieldMappingConfig,
|
||||
} from "../../lib/formstack-field-mapping.js";
|
||||
|
||||
const logger = createLogger('create-ticket-from-form');
|
||||
const logger = createLogger("create-ticket-from-form");
|
||||
|
||||
export interface CreateTicketFromFormOptions {
|
||||
formData: any;
|
||||
|
|
@ -26,40 +26,49 @@ const createTicketFromFormTask = async (
|
|||
const mapping = loadFieldMapping();
|
||||
|
||||
// Log only non-PII metadata using configured field names
|
||||
const formId = getFieldValue(formData, 'formId', mapping);
|
||||
const uniqueId = getFieldValue(formData, 'uniqueId', mapping);
|
||||
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');
|
||||
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 nameField = getFieldValue(formData, "name", mapping);
|
||||
const firstName = mapping.nestedFields?.name?.firstNamePath
|
||||
? getNestedFieldValue(nameField, 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';
|
||||
? 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);
|
||||
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');
|
||||
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
|
||||
|
|
@ -72,10 +81,10 @@ const createTicketFromFormTask = async (
|
|||
|
||||
// Build article body - format all fields as HTML
|
||||
const formatAllFields = (data: any): string => {
|
||||
let html = '';
|
||||
let html = "";
|
||||
|
||||
// Add formatted name field first if we have it
|
||||
if (fullName && fullName !== 'Unknown') {
|
||||
if (fullName && fullName !== "Unknown") {
|
||||
html += `<strong>Name:</strong><br>${fullName}<br>`;
|
||||
}
|
||||
|
||||
|
|
@ -84,15 +93,18 @@ const createTicketFromFormTask = async (
|
|||
const skipFields = [
|
||||
mapping.sourceFields.formId,
|
||||
mapping.sourceFields.uniqueId,
|
||||
mapping.sourceFields.name, // Skip raw name field
|
||||
'HandshakeKey',
|
||||
mapping.sourceFields.name, // Skip raw name field
|
||||
"HandshakeKey",
|
||||
].filter(Boolean);
|
||||
|
||||
if (skipFields.includes(key)) continue;
|
||||
if (value === null || value === undefined || value === '') continue;
|
||||
if (value === null || value === undefined || value === "") continue;
|
||||
|
||||
const displayValue = Array.isArray(value) ? value.join(', ') :
|
||||
typeof value === 'object' ? JSON.stringify(value) : value;
|
||||
const displayValue = Array.isArray(value)
|
||||
? value.join(", ")
|
||||
: typeof value === "object"
|
||||
? JSON.stringify(value)
|
||||
: value;
|
||||
html += `<strong>${key}:</strong><br>${displayValue}<br>`;
|
||||
}
|
||||
return html;
|
||||
|
|
@ -101,12 +113,12 @@ const createTicketFromFormTask = async (
|
|||
const body = formatAllFields(formData);
|
||||
|
||||
// Get Zammad configuration from environment
|
||||
const zammadUrl = process.env.ZAMMAD_URL || 'http://zammad-nginx:8080';
|
||||
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');
|
||||
logger.error("ZAMMAD_API_TOKEN environment variable is not configured");
|
||||
throw new Error("ZAMMAD_API_TOKEN is required");
|
||||
}
|
||||
|
||||
const zammad = Zammad({ token: zammadToken }, zammadUrl);
|
||||
|
|
@ -115,35 +127,39 @@ const createTicketFromFormTask = async (
|
|||
// 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);
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
logger.warn({ error: error.message }, "Failed to look up article type");
|
||||
}
|
||||
|
||||
// Get or create user
|
||||
// Try to find existing user by: signalAccount -> phone -> email
|
||||
// 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 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) {
|
||||
// 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');
|
||||
logger.info(
|
||||
{ customerId: customer.id, method: "phone" },
|
||||
"Found existing user by phone",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,22 +171,25 @@ const createTicketFromFormTask = async (
|
|||
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');
|
||||
logger.info(
|
||||
{ customerId: customer.id, method: "email" },
|
||||
"Found existing user by email",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.warn({ email }, 'Invalid email format provided, skipping email search');
|
||||
logger.warn({ email }, "Invalid email format provided, skipping email search");
|
||||
}
|
||||
}
|
||||
|
||||
if (!customer) {
|
||||
// Create new user
|
||||
logger.info('Creating new user from form submission');
|
||||
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'],
|
||||
roles: ["Customer"],
|
||||
};
|
||||
|
||||
// Add contact info only if provided
|
||||
|
|
@ -178,47 +197,105 @@ const createTicketFromFormTask = async (
|
|||
userData.email = email;
|
||||
}
|
||||
|
||||
const userPhone = signalAccount || phone;
|
||||
if (userPhone) {
|
||||
userData.phone = userPhone;
|
||||
// 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');
|
||||
logger.info(
|
||||
{
|
||||
customerId: customer.id,
|
||||
email: customer.email,
|
||||
},
|
||||
"Using customer for ticket",
|
||||
);
|
||||
|
||||
// Look up the configured group
|
||||
const groups = await zammad.get('groups');
|
||||
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');
|
||||
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');
|
||||
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 signalChannel = null;
|
||||
|
||||
if (signalAccount) {
|
||||
try {
|
||||
logger.info({ signalAccount }, "Looking up Signal channel and article type");
|
||||
|
||||
// Look up Signal channels (admin-only endpoint)
|
||||
const channels = await zammad.get("cdr_signal_channels");
|
||||
if (channels.length > 0) {
|
||||
signalChannel = channels[0]; // Use first active Signal channel
|
||||
logger.info(
|
||||
{
|
||||
channelId: signalChannel.id,
|
||||
phoneNumber: signalChannel.phone_number,
|
||||
},
|
||||
"Found active Signal channel",
|
||||
);
|
||||
} 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',
|
||||
subject: descriptionOfIssue || "Support Request",
|
||||
body,
|
||||
content_type: 'text/html',
|
||||
content_type: "text/html",
|
||||
internal: false,
|
||||
};
|
||||
|
||||
if (articleTypeId) {
|
||||
// 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 = {
|
||||
const ticketData: any = {
|
||||
title,
|
||||
group_id: targetGroup.id,
|
||||
customer_id: customer.id,
|
||||
|
|
@ -226,29 +303,84 @@ const createTicketFromFormTask = async (
|
|||
...customFields,
|
||||
};
|
||||
|
||||
logger.info({
|
||||
title,
|
||||
groupId: targetGroup.id,
|
||||
customerId: customer.id,
|
||||
hasArticleType: !!articleTypeId,
|
||||
customFieldCount: Object.keys(customFields).length,
|
||||
}, 'Creating ticket');
|
||||
// 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 (signalChannel && signalArticleType && signalAccount) {
|
||||
ticketData.preferences = {
|
||||
channel_id: signalChannel.id,
|
||||
cdr_signal: {
|
||||
bot_token: signalChannel.bot_token,
|
||||
chat_id: signalAccount, // Use Signal phone number as chat_id
|
||||
},
|
||||
};
|
||||
|
||||
logger.info(
|
||||
{
|
||||
channelId: signalChannel.id,
|
||||
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);
|
||||
|
||||
logger.info({
|
||||
ticketId: ticket.id,
|
||||
ticketNumber: ticket.id,
|
||||
title,
|
||||
}, 'Successfully created ticket from Formstack submission');
|
||||
// Set create_article_type_id for Signal tickets to enable proper replies
|
||||
if (signalArticleType && signalChannel) {
|
||||
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: !!signalChannel,
|
||||
},
|
||||
"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');
|
||||
logger.error(
|
||||
{
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
formId,
|
||||
uniqueId,
|
||||
},
|
||||
"Failed to create ticket from Formstack submission",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue