Repo cleanup and updates
This commit is contained in:
parent
3a1063e40e
commit
99f8d7e2eb
72 changed files with 11857 additions and 16439 deletions
|
|
@ -1,5 +1,14 @@
|
|||
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');
|
||||
|
||||
|
|
@ -13,63 +22,73 @@ const createTicketFromFormTask = async (
|
|||
): Promise<void> => {
|
||||
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({
|
||||
formData,
|
||||
formId,
|
||||
uniqueId,
|
||||
receivedAt,
|
||||
formDataKeys: Object.keys(formData),
|
||||
fieldCount: Object.keys(formData).length
|
||||
}, 'Processing Formstack form submission');
|
||||
|
||||
// Extract data from Formstack payload - matching Python ngo-isac-uploader field names
|
||||
const {
|
||||
FormID,
|
||||
UniqueID,
|
||||
Name,
|
||||
Email,
|
||||
Phone,
|
||||
'Signal Account': signalAccount,
|
||||
City,
|
||||
State,
|
||||
'Zip Code': zipCode,
|
||||
'What organization are you affiliated with and/or employed by (if applicable)?': organization,
|
||||
'What type of support do you wish to receive (to the extent you know)?': typeOfSupport,
|
||||
'Is there a specific deadline associated with this request (e.g., a legal or legislative deadline)?': specificDeadline,
|
||||
'Please provide the deadline': deadline,
|
||||
'Do you have an insurance provider that provides coverage for the types of services you seek (e.g., public official, professional liability insurance, litigation insurance)?': hasInsuranceProvider,
|
||||
'Have you approached the insurance provider for assistance?': approachedProvider,
|
||||
'Are you seeking help on behalf of an individual or an organization?': typeOfUser,
|
||||
'What is the structure of the organization?': orgStructure,
|
||||
'Are you currently a candidate for elected office, a government officeholder, or a government employee?': governmentAffiliated,
|
||||
'Where did you hear about the Democracy Protection Network?': whereHeard,
|
||||
'Do you or the organization work on behalf of any of the following communities or issues? Please select all that apply.': relatedIssues,
|
||||
'Do you or the organization engage in any of the following types of work? Please select all that apply.': typeOfWork,
|
||||
'Why are you seeking support? Please briefly describe the circumstances that have brought you to the DPN, including, as applicable, dates, places, and the people or entities involved. We coordinate crisis-response services and some resilience-building services (e.g., assistance establishing good-governance or security practices). If you are seeking resilience-building services, please note that in the text box below.': descriptionOfIssue,
|
||||
'What is your preferred communication method?': preferredContactMethod,
|
||||
} = formData;
|
||||
|
||||
// Build full name - matching Python pattern
|
||||
const firstName = Name?.first || '';
|
||||
const lastName = Name?.last || '';
|
||||
// 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';
|
||||
|
||||
// Build ticket title - exactly matching Python ngo-isac-uploader pattern
|
||||
// Pattern: [Name] - [Organization] - [Type of support]
|
||||
let title = fullName;
|
||||
if (organization) {
|
||||
title += ` - ${organization}`;
|
||||
}
|
||||
if (typeOfSupport) {
|
||||
// Handle array format (Formstack sends arrays for multi-select)
|
||||
const supportText = Array.isArray(typeOfSupport) ? typeOfSupport.join(', ') : typeOfSupport;
|
||||
title += ` - ${supportText}`;
|
||||
// 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 article body - format all fields as HTML like Python does
|
||||
// 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 += `<strong>Name:</strong><br>${fullName}<br>`;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key === 'HandshakeKey' || key === 'FormID' || key === 'UniqueID') continue;
|
||||
// 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(', ') :
|
||||
|
|
@ -93,130 +112,143 @@ const createTicketFromFormTask = async (
|
|||
const zammad = Zammad({ token: zammadToken }, zammadUrl);
|
||||
|
||||
try {
|
||||
// Look up the article type ID for cdr_signal
|
||||
let cdrSignalTypeId: number | undefined;
|
||||
// Look up the configured article type
|
||||
let articleTypeId: number | undefined;
|
||||
try {
|
||||
const articleTypes = await zammad.get('ticket_article_types');
|
||||
const cdrSignalType = articleTypes.find((t: any) => t.name === 'cdr_signal');
|
||||
cdrSignalTypeId = cdrSignalType?.id;
|
||||
if (cdrSignalTypeId) {
|
||||
logger.info({ cdrSignalTypeId }, 'Found cdr_signal article type');
|
||||
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('cdr_signal 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 cdr_signal article type');
|
||||
logger.warn({ error: error.message }, 'Failed to look up article type');
|
||||
}
|
||||
|
||||
// Determine contact method and phone number - matching Python logic
|
||||
// Priority: Signal > SMS/Phone > Email
|
||||
const useSignal = preferredContactMethod?.includes('Signal') || preferredContactMethod?.includes('ignal');
|
||||
const useSMS = preferredContactMethod?.includes('SMS');
|
||||
const phoneNumber = useSignal ? signalAccount : (useSMS || Phone) ? Phone : '';
|
||||
|
||||
// Get or create user - matching Python pattern
|
||||
// Get or create user
|
||||
// Try to find existing user by: signalAccount -> phone -> email
|
||||
let customer;
|
||||
|
||||
if (phoneNumber) {
|
||||
// Try to find by phone (Signal or regular)
|
||||
customer = await getUser(zammad, phoneNumber);
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
if (!customer && Email) {
|
||||
// Search by email if phone search didn't work
|
||||
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');
|
||||
// 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 - matching Python user creation pattern
|
||||
// Create new user
|
||||
logger.info('Creating new user from form submission');
|
||||
customer = await zammad.user.create({
|
||||
|
||||
// Build user data with whatever contact info we have
|
||||
const userData: any = {
|
||||
firstname: firstName,
|
||||
lastname: lastName,
|
||||
email: Email || `${UniqueID}@formstack.local`,
|
||||
phone: phoneNumber || '',
|
||||
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,
|
||||
customerEmail: customer.email,
|
||||
customerPhone: customer.phone,
|
||||
}, 'Customer identified/created');
|
||||
email: customer.email,
|
||||
}, 'Using customer for ticket');
|
||||
|
||||
// Helper function to format field values (handle arrays and null values)
|
||||
const formatFieldValue = (value: any): string | undefined => {
|
||||
if (value === null || value === undefined || value === '') return undefined;
|
||||
if (Array.isArray(value)) return value.join(', ');
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
// 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,
|
||||
};
|
||||
|
||||
// Create the ticket with custom fields - EXACTLY matching Python ngo-isac-uploader field names
|
||||
const ticketData: any = {
|
||||
if (articleTypeId) {
|
||||
articleData.type_id = articleTypeId;
|
||||
}
|
||||
|
||||
const ticketData = {
|
||||
title,
|
||||
group: "Imports", // Matching Python - uses "Imports" group
|
||||
group_id: targetGroup.id,
|
||||
customer_id: customer.id,
|
||||
|
||||
// Custom fields - matching Python field names EXACTLY
|
||||
us_state: formatFieldValue(State),
|
||||
zip_code: formatFieldValue(zipCode),
|
||||
city: formatFieldValue(City),
|
||||
type_of_support: formatFieldValue(typeOfSupport),
|
||||
specific_deadline: formatFieldValue(specificDeadline),
|
||||
deadline: formatFieldValue(deadline),
|
||||
has_insurance_provider: formatFieldValue(hasInsuranceProvider),
|
||||
approached_provider: formatFieldValue(approachedProvider),
|
||||
type_of_user: formatFieldValue(typeOfUser),
|
||||
org_structure: formatFieldValue(orgStructure),
|
||||
government_affiliated: formatFieldValue(governmentAffiliated),
|
||||
where_heard: formatFieldValue(whereHeard),
|
||||
related_issues: formatFieldValue(relatedIssues),
|
||||
type_of_work: formatFieldValue(typeOfWork),
|
||||
|
||||
// Article with all formatted fields
|
||||
article: {
|
||||
body,
|
||||
subject: title,
|
||||
content_type: "text/html",
|
||||
type: useSignal ? "cdr_signal" : "note",
|
||||
from: phoneNumber || Email || 'unknown',
|
||||
sender: "Customer",
|
||||
},
|
||||
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);
|
||||
|
||||
// Update the ticket with the cdr_signal article type
|
||||
// This must be done after creation as Zammad doesn't allow setting this field during creation
|
||||
if (cdrSignalTypeId) {
|
||||
await zammad.ticket.update(ticket.id, { create_article_type_id: cdrSignalTypeId });
|
||||
logger.info({ ticketId: ticket.id, cdrSignalTypeId }, 'Updated ticket with cdr_signal article type');
|
||||
}
|
||||
|
||||
logger.info({
|
||||
ticketId: ticket.id,
|
||||
customerId: customer.id,
|
||||
formId: FormID,
|
||||
submissionId: UniqueID,
|
||||
}, 'Zammad ticket created successfully');
|
||||
ticketNumber: ticket.id,
|
||||
title,
|
||||
}, 'Successfully created ticket from Formstack submission');
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error({
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
output: error.output,
|
||||
formId: FormID,
|
||||
submissionId: UniqueID,
|
||||
}, 'Failed to create Zammad ticket');
|
||||
formId,
|
||||
uniqueId,
|
||||
}, 'Failed to create ticket from Formstack submission');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||
import {
|
||||
db,
|
||||
getWorkerUtils,
|
||||
getMaxAttachmentSize,
|
||||
getMaxTotalAttachmentSize,
|
||||
MAX_ATTACHMENTS,
|
||||
buildSignalGroupName,
|
||||
} from "@link-stack/bridge-common";
|
||||
import { createLogger } from "@link-stack/logger";
|
||||
import * as signalApi from "@link-stack/signal-api";
|
||||
const { Configuration, MessagesApi, GroupsApi } = signalApi;
|
||||
|
|
@ -81,7 +88,7 @@ const sendSignalMessageTask = async ({
|
|||
// If sending to a phone number and auto-groups is enabled, create a group first
|
||||
if (enableAutoGroups && !isGroupId && to && conversationId) {
|
||||
try {
|
||||
const groupName = `DPN Support Request: ${conversationId}`;
|
||||
const groupName = buildSignalGroupName(conversationId);
|
||||
const createGroupResponse = await groupsClient.v1GroupsNumberPost({
|
||||
number: bot.phoneNumber,
|
||||
data: {
|
||||
|
|
@ -204,16 +211,54 @@ const sendSignalMessageTask = async ({
|
|||
);
|
||||
}
|
||||
|
||||
// Add attachments if provided
|
||||
// Add attachments if provided with size validation
|
||||
if (attachments && attachments.length > 0) {
|
||||
messageData.base64Attachments = attachments.map((att) => att.data);
|
||||
logger.debug(
|
||||
{
|
||||
attachmentCount: attachments.length,
|
||||
attachmentNames: attachments.map((att) => att.filename),
|
||||
},
|
||||
"Including attachments in message",
|
||||
);
|
||||
const MAX_ATTACHMENT_SIZE = getMaxAttachmentSize();
|
||||
const MAX_TOTAL_SIZE = getMaxTotalAttachmentSize();
|
||||
|
||||
if (attachments.length > MAX_ATTACHMENTS) {
|
||||
throw new Error(`Too many attachments: ${attachments.length} (max ${MAX_ATTACHMENTS})`);
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
const validatedAttachments = [];
|
||||
|
||||
for (const attachment of attachments) {
|
||||
// Calculate size from base64 string (rough estimate: length * 3/4)
|
||||
const estimatedSize = (attachment.data.length * 3) / 4;
|
||||
|
||||
if (estimatedSize > MAX_ATTACHMENT_SIZE) {
|
||||
logger.warn({
|
||||
filename: attachment.filename,
|
||||
size: estimatedSize,
|
||||
maxSize: MAX_ATTACHMENT_SIZE
|
||||
}, 'Attachment exceeds size limit, skipping');
|
||||
continue;
|
||||
}
|
||||
|
||||
totalSize += estimatedSize;
|
||||
if (totalSize > MAX_TOTAL_SIZE) {
|
||||
logger.warn({
|
||||
totalSize,
|
||||
maxTotalSize: MAX_TOTAL_SIZE
|
||||
}, 'Total attachment size exceeds limit, skipping remaining');
|
||||
break;
|
||||
}
|
||||
|
||||
validatedAttachments.push(attachment.data);
|
||||
}
|
||||
|
||||
if (validatedAttachments.length > 0) {
|
||||
messageData.base64Attachments = validatedAttachments;
|
||||
logger.debug(
|
||||
{
|
||||
attachmentCount: validatedAttachments.length,
|
||||
attachmentNames: attachments.slice(0, validatedAttachments.length).map((att) => att.filename),
|
||||
totalSizeBytes: totalSize
|
||||
},
|
||||
"Including attachments in message",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await messagesClient.v2SendPost({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue