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

437 lines
14 KiB
TypeScript
Raw Normal View History

2025-10-15 16:08:53 +02:00
import { createLogger } from "@link-stack/logger";
2025-11-13 11:18:08 +01:00
import { db } from "@link-stack/bridge-common";
import { Zammad, getUser, sanitizePhoneNumber } 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
2025-11-13 10:42:16 +01:00
const logger = createLogger("create-ticket-from-form");
2025-10-15 16:08:53 +02:00
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
2025-11-13 10:42:16 +01:00
const formId = getFieldValue(formData, "formId", mapping);
const uniqueId = getFieldValue(formData, "uniqueId", mapping);
2025-11-10 14:55:22 +01:00
2025-11-13 10:42:16 +01:00
logger.info(
{
formId,
uniqueId,
receivedAt,
fieldCount: Object.keys(formData).length,
},
"Processing Formstack form submission",
);
2025-10-15 16:08:53 +02:00
2025-11-10 14:55:22 +01:00
// Extract fields using dynamic mapping
2025-11-13 10:42:16 +01:00
const nameField = getFieldValue(formData, "name", mapping);
2025-11-10 14:55:22 +01:00
const firstName = mapping.nestedFields?.name?.firstNamePath
2025-11-13 10:42:16 +01:00
? getNestedFieldValue(nameField, mapping.nestedFields.name.firstNamePath) || ""
: "";
2025-11-10 14:55:22 +01:00
const lastName = mapping.nestedFields?.name?.lastNamePath
2025-11-13 10:42:16 +01:00
? getNestedFieldValue(nameField, mapping.nestedFields.name.lastNamePath) || ""
: "";
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)
2025-11-13 10:42:16 +01:00
const email = getFieldValue(formData, "email", mapping);
const rawPhone = getFieldValue(formData, "phone", mapping);
const rawSignalAccount = getFieldValue(formData, "signalAccount", mapping);
2025-11-13 10:42:16 +01:00
const organization = getFieldValue(formData, "organization", mapping);
const typeOfSupport = getFieldValue(formData, "typeOfSupport", mapping);
const descriptionOfIssue = getFieldValue(formData, "descriptionOfIssue", mapping);
2025-11-10 14:55:22 +01:00
// Sanitize phone numbers to E.164 format (+15554446666)
let phone: string | undefined;
if (rawPhone) {
try {
phone = sanitizePhoneNumber(rawPhone);
logger.info({ rawPhone, sanitized: phone }, "Sanitized phone number");
} catch (error: any) {
logger.warn({ rawPhone, error: error.message }, "Invalid phone number format, ignoring");
phone = undefined;
}
}
let signalAccount: string | undefined;
if (rawSignalAccount) {
try {
signalAccount = sanitizePhoneNumber(rawSignalAccount);
logger.info({ rawSignalAccount, sanitized: signalAccount }, "Sanitized signal account");
} catch (error: any) {
logger.warn({ rawSignalAccount, error: error.message }, "Invalid signal account format, ignoring");
signalAccount = undefined;
}
}
2025-11-10 14:55:22 +01:00
// Validate that at least one contact method is provided
if (!email && !phone && !signalAccount) {
2025-11-13 10:42:16 +01:00
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 => {
2025-11-13 10:42:16 +01:00
let html = "";
2025-11-10 14:55:22 +01:00
// Add formatted name field first if we have it
2025-11-13 10:42:16 +01:00
if (fullName && fullName !== "Unknown") {
2025-11-10 14:55:22 +01:00
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,
2025-11-13 10:42:16 +01:00
mapping.sourceFields.name, // Skip raw name field
"HandshakeKey",
2025-11-10 14:55:22 +01:00
].filter(Boolean);
if (skipFields.includes(key)) continue;
2025-11-13 10:42:16 +01:00
if (value === null || value === undefined || value === "") continue;
2025-10-26 15:39:55 +01:00
2025-11-13 10:42:16 +01:00
const displayValue = Array.isArray(value)
? value.join(", ")
: typeof value === "object"
? JSON.stringify(value)
: value;
2025-10-27 21:02:19 +01:00
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
2025-11-13 10:42:16 +01:00
const zammadUrl = process.env.ZAMMAD_URL || "http://zammad-nginx:8080";
2025-10-15 16:08:53 +02:00
const zammadToken = process.env.ZAMMAD_API_TOKEN;
if (!zammadToken) {
2025-11-13 10:42:16 +01:00
logger.error("ZAMMAD_API_TOKEN environment variable is not configured");
throw new Error("ZAMMAD_API_TOKEN is required");
2025-10-15 16:08:53 +02:00
}
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 {
2025-11-13 10:42:16 +01:00
const articleTypes = await zammad.get("ticket_article_types");
const configuredType = articleTypes.find(
(t: any) => t.name === mapping.ticket.defaultArticleType,
);
2025-11-10 14:55:22 +01:00
articleTypeId = configuredType?.id;
if (articleTypeId) {
2025-11-13 10:42:16 +01:00
logger.info(
{ articleTypeId, typeName: mapping.ticket.defaultArticleType },
"Found configured article type",
);
2025-10-27 21:02:19 +01:00
} else {
2025-11-13 10:42:16 +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-13 10:42:16 +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
2025-11-13 10:42:16 +01:00
// Try to find existing user by: phone -> email
// Note: We can't search by Signal account since Signal group IDs aren't phone numbers
2025-10-15 16:08:53 +02:00
let customer;
2025-11-13 10:42:16 +01:00
// Try phone if provided
if (phone) {
2025-11-10 14:55:22 +01:00
customer = await getUser(zammad, phone);
2025-10-26 15:39:55 +01:00
if (customer) {
2025-11-13 10:42:16 +01:00
logger.info(
{ customerId: customer.id, method: "phone" },
"Found existing user by phone",
);
2025-10-26 15:39:55 +01:00
}
}
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];
2025-11-13 10:42:16 +01:00
logger.info(
{ customerId: customer.id, method: "email" },
"Found existing user by email",
);
2025-11-10 14:55:22 +01:00
}
} else {
2025-11-13 10:42:16 +01:00
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-11-13 10:42:16 +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,
2025-11-13 10:42:16 +01:00
roles: ["Customer"],
2025-11-10 14:55:22 +01:00
};
// Add contact info only if provided
if (email) {
userData.email = email;
}
2025-11-13 10:42:16 +01:00
// Use phone number if provided (don't use Signal group ID as phone)
if (phone) {
userData.phone = phone;
2025-11-10 14:55:22 +01:00
}
customer = await zammad.user.create(userData);
2025-10-15 16:08:53 +02:00
}
2025-11-13 10:42:16 +01:00
logger.info(
{
customerId: customer.id,
email: customer.email,
},
"Using customer for ticket",
);
2025-11-10 14:55:22 +01:00
// Look up the configured group
2025-11-13 10:42:16 +01:00
const groups = await zammad.get("groups");
2025-11-10 14:55:22 +01:00
const targetGroup = groups.find((g: any) => g.name === mapping.ticket.group);
if (!targetGroup) {
2025-11-13 10:42:16 +01:00
logger.error({ groupName: mapping.ticket.group }, "Configured group not found");
2025-11-10 14:55:22 +01:00
throw new Error(`Zammad group "${mapping.ticket.group}" not found`);
}
2025-11-13 10:42:16 +01:00
logger.info(
{ groupId: targetGroup.id, groupName: targetGroup.name },
"Using configured group",
);
2025-11-10 14:55:22 +01:00
// Build custom fields using Zammad field mapping
// This dynamically maps all configured fields without hardcoding
const customFields = getZammadFieldValues(formData, mapping);
2025-11-13 10:42:16 +01:00
// Check if this is a Signal ticket
let signalArticleType = null;
2025-11-13 11:18:08 +01:00
let signalChannelId = null;
let signalBotToken = null;
2025-11-13 10:42:16 +01:00
if (signalAccount) {
try {
logger.info({ signalAccount }, "Looking up Signal channel and article type");
2025-11-13 11:18:08 +01:00
// Look up Signal channels from Zammad (admin-only endpoint)
// Note: bot_token is NOT included in this response for security reasons
2025-11-13 10:42:16 +01:00
const channels = await zammad.get("cdr_signal_channels");
if (channels.length > 0) {
2025-11-13 11:18:08 +01:00
const zammadChannel = channels[0]; // Use first active Signal channel
signalChannelId = zammadChannel.id;
2025-11-13 10:42:16 +01:00
logger.info(
{
2025-11-13 11:18:08 +01:00
channelId: zammadChannel.id,
phoneNumber: zammadChannel.phone_number,
2025-11-13 10:42:16 +01:00
},
2025-11-13 11:18:08 +01:00
"Found active Signal channel from Zammad",
2025-11-13 10:42:16 +01:00
);
2025-11-13 11:18:08 +01:00
// 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",
);
}
2025-11-13 10:42:16 +01:00
} 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",
);
}
}
2025-11-10 14:55:22 +01:00
// Create the ticket
const articleData: any = {
2025-11-13 10:42:16 +01:00
subject: descriptionOfIssue || "Support Request",
2025-11-10 14:55:22 +01:00
body,
2025-11-13 10:42:16 +01:00
content_type: "text/html",
2025-11-10 14:55:22 +01:00
internal: false,
2025-10-27 21:02:19 +01:00
};
2025-10-26 15:39:55 +01:00
2025-11-13 10:42:16 +01:00
// 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) {
2025-11-10 14:55:22 +01:00
articleData.type_id = articleTypeId;
}
2025-11-13 10:42:16 +01:00
const ticketData: any = {
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-13 10:42:16 +01:00
// 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
2025-11-13 11:18:08 +01:00
if (signalChannelId && signalBotToken && signalArticleType && signalAccount) {
2025-11-13 10:42:16 +01:00
ticketData.preferences = {
2025-11-13 11:18:08 +01:00
channel_id: signalChannelId,
2025-11-13 10:42:16 +01:00
cdr_signal: {
2025-11-13 11:18:08 +01:00
bot_token: signalBotToken,
2025-11-13 10:42:16 +01:00
chat_id: signalAccount, // Use Signal phone number as chat_id
},
};
logger.info(
{
2025-11-13 11:18:08 +01:00
channelId: signalChannelId,
2025-11-13 10:42:16 +01:00
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",
);
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
2025-11-13 10:42:16 +01:00
// Set create_article_type_id for Signal tickets to enable proper replies
2025-11-13 11:18:08 +01:00
if (signalArticleType && signalChannelId) {
2025-11-13 10:42:16 +01:00
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",
);
}
}
2025-10-15 16:08:53 +02:00
2025-11-13 10:42:16 +01:00
logger.info(
{
ticketId: ticket.id,
ticketNumber: ticket.id,
title,
2025-11-13 11:18:08 +01:00
isSignalTicket: !!signalChannelId,
2025-11-13 10:42:16 +01:00
},
"Successfully created ticket from Formstack submission",
);
2025-10-15 16:08:53 +02:00
} catch (error: any) {
2025-11-13 10:42:16 +01:00
logger.error(
{
error: error.message,
stack: error.stack,
formId,
uniqueId,
},
"Failed to create ticket from Formstack submission",
);
2025-10-15 16:08:53 +02:00
throw error;
}
};
export default createTicketFromFormTask;