From 20078ccaccd622105922ac2f3aafd3682e8d15e2 Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Mon, 27 Oct 2025 21:02:19 +0100 Subject: [PATCH] Next release WIP # --- .gitignore | 1 + apps/bridge-worker/lib/zammad.ts | 10 + .../formstack/create-ticket-from-form.ts | 199 +++++++++++------- docker/compose/bridge.yml | 2 + docker/zammad/Dockerfile | 21 +- .../article_action/cdr_signal.coffee | 8 +- .../cdr_ticket_article_types_controller.rb | 17 ++ ..._ticket_article_types_controller_policy.rb | 9 + .../config/routes/cdr_ticket_article_types.rb | 5 + .../20250105000003_add_cdr_link_config.rb | 41 ++++ 10 files changed, 219 insertions(+), 94 deletions(-) create mode 100644 packages/zammad-addon-bridge/src/app/controllers/cdr_ticket_article_types_controller.rb create mode 100644 packages/zammad-addon-bridge/src/app/policies/controllers/cdr_ticket_article_types_controller_policy.rb create mode 100644 packages/zammad-addon-bridge/src/config/routes/cdr_ticket_article_types.rb create mode 100644 packages/zammad-addon-bridge/src/db/addon/bridge/20250105000003_add_cdr_link_config.rb diff --git a/.gitignore b/.gitignore index eb15c89..1c74a84 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ baileys-state signald-state project.org **/.openapi-generator/ +apps/bridge-worker/scripts/* diff --git a/apps/bridge-worker/lib/zammad.ts b/apps/bridge-worker/lib/zammad.ts index b3faa82..e7cfcb6 100644 --- a/apps/bridge-worker/lib/zammad.ts +++ b/apps/bridge-worker/lib/zammad.ts @@ -19,11 +19,13 @@ export interface Ticket { export interface ZammadClient { ticket: { create: (data: any) => Promise; + update: (id: number, data: any) => Promise; }; user: { search: (data: any) => Promise; create: (data: any) => Promise; }; + get: (path: string) => Promise; } export type ZammadCredentials = @@ -73,6 +75,10 @@ export const Zammad = ( const { payload: result } = await wreck.post("tickets", { payload }); return result as Ticket; }, + update: async (id, payload) => { + const { payload: result } = await wreck.put(`tickets/${id}`, { payload }); + return result as Ticket; + }, }, user: { search: async (query) => { @@ -85,6 +91,10 @@ export const Zammad = ( return result as User; }, }, + get: async (path) => { + const { payload: result } = await wreck.get(path); + return result; + }, }; }; diff --git a/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts b/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts index 84062dc..8249f04 100644 --- a/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts +++ b/apps/bridge-worker/tasks/formstack/create-ticket-from-form.ts @@ -19,53 +19,67 @@ const createTicketFromFormTask = async ( formDataKeys: Object.keys(formData), }, 'Processing Formstack form submission'); - // Extract data from Formstack payload + // Extract data from Formstack payload - matching Python ngo-isac-uploader field names const { FormID, UniqueID, - name, - signal_number, - type_of_help_requested, - type_of_organization, - urgency_level, - email_address, - phone_number, - address, - description_of_issue, - preferred_contact_method, - available_times_for_contact, - how_did_you_hear_about_us, - preferred_language, + 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 - const fullName = name - ? `${name.first || ''} ${name.last || ''}`.trim() - : 'Unknown'; + // Build full name - matching Python pattern + const firstName = Name?.first || ''; + const lastName = Name?.last || ''; + const fullName = (firstName && lastName) + ? `${firstName} ${lastName}`.trim() + : firstName || lastName || 'Unknown'; - // Get organization name from form data - const organizationName = type_of_organization || ''; - - // Build ticket title - matching ngo-isac-uploader pattern + // Build ticket title - exactly matching Python ngo-isac-uploader pattern + // Pattern: [Name] - [Organization] - [Type of support] let title = fullName; - if (organizationName) { - title += ` - ${organizationName}`; + if (organization) { + title += ` - ${organization}`; } - if (type_of_help_requested) { - title += ` - ${type_of_help_requested}`; + if (typeOfSupport) { + // Handle array format (Formstack sends arrays for multi-select) + const supportText = Array.isArray(typeOfSupport) ? typeOfSupport.join(', ') : typeOfSupport; + title += ` - ${supportText}`; } - // Build article body - only description and metadata - // All other fields go into custom Zammad ticket fields - const body = description_of_issue - ? `

${description_of_issue}

+ // Build article body - format all fields as HTML like Python does + const formatAllFields = (data: any): string => { + let html = ''; + for (const [key, value] of Object.entries(data)) { + if (key === 'HandshakeKey' || key === 'FormID' || key === 'UniqueID') continue; + if (value === null || value === undefined || value === '') continue; -
-

Submitted via Formstack | Form ID: ${FormID} | Submission ID: ${UniqueID} | Received: ${receivedAt}

` - : `

No description provided

+ const displayValue = Array.isArray(value) ? value.join(', ') : + typeof value === 'object' ? JSON.stringify(value) : value; + html += `${key}:
${displayValue}
`; + } + return html; + }; -
-

Submitted via Formstack | Form ID: ${FormID} | Submission ID: ${UniqueID} | Received: ${receivedAt}

`; + const body = formatAllFields(formData); // Get Zammad configuration from environment const zammadUrl = process.env.ZAMMAD_URL || 'http://zammad-nginx:8080'; @@ -79,22 +93,41 @@ const createTicketFromFormTask = async ( const zammad = Zammad({ token: zammadToken }, zammadUrl); try { - // Get or create user based on contact info - // Priority: signal_number > phone_number > email_address + // Look up the article type ID for cdr_signal + let cdrSignalTypeId: 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'); + } else { + logger.warn('cdr_signal 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'); + } + + // 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 let customer; - // Try to find existing user by phone or email - if (signal_number || phone_number) { - const phoneToSearch = signal_number || phone_number; - customer = await getUser(zammad, phoneToSearch); + if (phoneNumber) { + // Try to find by phone (Signal or regular) + customer = await getUser(zammad, phoneNumber); if (customer) { logger.info({ customerId: customer.id, method: 'phone' }, 'Found existing user by phone'); } } - if (!customer && email_address) { + if (!customer && Email) { // Search by email if phone search didn't work - const emailResults = await zammad.user.search(`email:${email_address}`); + 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'); @@ -102,14 +135,14 @@ const createTicketFromFormTask = async ( } if (!customer) { - // Create new user with all available contact information + // Create new user - matching Python user creation pattern logger.info('Creating new user from form submission'); customer = await zammad.user.create({ - firstname: name?.first || '', - lastname: name?.last || '', - email: email_address || `${UniqueID}@formstack.local`, - phone: signal_number || phone_number || '', - note: `User created from Formstack submission ${UniqueID}`, + firstname: firstName, + lastname: lastName, + email: Email || `${UniqueID}@formstack.local`, + phone: phoneNumber || '', + roles: ['Customer'], }); } @@ -119,45 +152,55 @@ const createTicketFromFormTask = async ( customerPhone: customer.phone, }, 'Customer identified/created'); - // Build address parts - const streetAddress = address?.address || ''; - const cityValue = address?.city || ''; - const stateValue = address?.state || ''; - const zipValue = address?.zip || ''; + // 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); + }; - // Create the ticket with custom fields mapped to Zammad attributes - // Following the pattern from ngo-isac-uploader where all form data - // goes into structured fields rather than HTML body - const ticket = await zammad.ticket.create({ + // Create the ticket with custom fields - EXACTLY matching Python ngo-isac-uploader field names + const ticketData: any = { title, - group: "Users", // Default group - you may want to make this configurable + group: "Imports", // Matching Python - uses "Imports" group customer_id: customer.id, - // Custom fields - these will be populated in Zammad's ticket attributes - // NOTE: 'organization', 'formstack_form_id', 'formstack_submission_id' - // fields could not be created due to naming conflicts, so metadata - // is included in the ticket body instead - signal_number: signal_number || undefined, - type_of_help_requested: type_of_help_requested || undefined, - type_of_organization: type_of_organization || undefined, - urgency_level: urgency_level || undefined, - city: cityValue || undefined, - us_state: stateValue || undefined, - zip_code: zipValue || undefined, - street_address: streetAddress || undefined, - preferred_contact_method: preferred_contact_method || undefined, - available_times: available_times_for_contact || undefined, - where_heard: how_did_you_hear_about_us || undefined, - preferred_language: preferred_language || undefined, + // 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 just the description + // Article with all formatted fields article: { body, subject: title, content_type: "text/html", - type: "note", + type: useSignal ? "cdr_signal" : "note", + from: phoneNumber || Email || 'unknown', + sender: "Customer", }, - }); + }; + + 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, diff --git a/docker/compose/bridge.yml b/docker/compose/bridge.yml index 62721d0..72150d5 100644 --- a/docker/compose/bridge.yml +++ b/docker/compose/bridge.yml @@ -22,6 +22,8 @@ x-bridge-vars: &common-bridge-variables BRIDGE_SIGNAL_URL: ${BRIDGE_SIGNAL_URL} BRIDGE_SIGNAL_AUTO_GROUPS: ${BRIDGE_SIGNAL_AUTO_GROUPS} LOG_LEVEL: "debug" + ZAMMAD_API_TOKEN: ${ZAMMAD_API_TOKEN} + ZAMMAD_URL: ${ZAMMAD_URL} services: bridge-frontend: diff --git a/docker/zammad/Dockerfile b/docker/zammad/Dockerfile index f48e56a..d16b21b 100644 --- a/docker/zammad/Dockerfile +++ b/docker/zammad/Dockerfile @@ -38,15 +38,14 @@ RUN bundle check || bundle install --jobs 8 # Install Node packages RUN pnpm install --frozen-lockfile -# CRITICAL: Install addons BEFORE asset compilation -# This extracts addon files including Vue components, TypeScript, and CSS +# CRITICAL: Install addons +# This extracts addon files including CoffeeScript, Vue components, TypeScript, and CSS RUN ruby contrib/link/install.rb -# Recompile assets with our addon components -# The base image has assets precompiled, but we need to recompile with our additions -# SKIP asset compilation during build - it will happen at runtime via entrypoint -# This is because asset compilation requires Redis which isn't available during build -# RUN bundle exec rake assets:precompile RAILS_SKIP_ASSET_COMPILATION=false || echo "Skipped" +# Precompile assets with addon CoffeeScript files included +# Use ZAMMAD_SAFE_MODE=1 and dummy DATABASE_URL to avoid needing real database +RUN touch db/schema.rb && \ + ZAMMAD_SAFE_MODE=1 DATABASE_URL=postgresql://zammad:/zammad bundle exec rake assets:precompile # Run additional setup for addons RUN bundle exec rails runner /opt/zammad/contrib/link/setup.rb || true @@ -69,14 +68,6 @@ RUN if [ "$EMBEDDED" = "true" ] ; then \ echo " }" >> /opt/zammad/contrib/nginx/zammad.conf && \ echo "}" >> /opt/zammad/contrib/nginx/zammad.conf; \ fi -RUN sed -i '/^[[:space:]]*# es config/a\ - echo "about to reinstall..."\n\ - bundle exec rails runner /opt/zammad/contrib/link/setup.rb\n\ - bundle exec rake zammad:package:migrate\n\ - echo "Recompiling assets with addon CoffeeScript files..."\n\ - bundle exec rake assets:precompile RAILS_SKIP_ASSET_COMPILATION=false\n\ - echo "Asset recompilation complete"\n\ - ' /docker-entrypoint.sh FROM zammad/zammad-docker-compose:${ZAMMAD_VERSION} AS runner USER root diff --git a/packages/zammad-addon-bridge/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_signal.coffee b/packages/zammad-addon-bridge/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_signal.coffee index 4dc703d..204ef98 100644 --- a/packages/zammad-addon-bridge/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_signal.coffee +++ b/packages/zammad-addon-bridge/src/app/assets/javascripts/app/controllers/ticket_zoom/article_action/cdr_signal.coffee @@ -47,8 +47,14 @@ class CdrSignalReply # Check CDR Link allowed channels setting allowedChannels = ui.Config.get('cdr_link_allowed_channels') - if allowedChannels && allowedChannels.trim() + hasWhitelist = allowedChannels && allowedChannels.trim() + + if hasWhitelist whitelist = (channel.trim() for channel in allowedChannels.split(',')) + # Filter articleTypes to only those in the whitelist (keep 'note' for internal notes) + articleTypes = articleTypes.filter (type) -> + type.name is 'note' or type.name in whitelist + # Return early if 'cdr_signal' or 'signal message' not in whitelist return articleTypes if 'cdr_signal' not in whitelist && 'signal message' not in whitelist diff --git a/packages/zammad-addon-bridge/src/app/controllers/cdr_ticket_article_types_controller.rb b/packages/zammad-addon-bridge/src/app/controllers/cdr_ticket_article_types_controller.rb new file mode 100644 index 0000000..da0a7d7 --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/controllers/cdr_ticket_article_types_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CdrTicketArticleTypesController < ApplicationController + prepend_before_action -> { authentication_check && authorize! } + + def index + types = Ticket::Article::Type.all.map do |type| + { + id: type.id, + name: type.name, + communication: type.communication + } + end + + render json: types + end +end diff --git a/packages/zammad-addon-bridge/src/app/policies/controllers/cdr_ticket_article_types_controller_policy.rb b/packages/zammad-addon-bridge/src/app/policies/controllers/cdr_ticket_article_types_controller_policy.rb new file mode 100644 index 0000000..ef56342 --- /dev/null +++ b/packages/zammad-addon-bridge/src/app/policies/controllers/cdr_ticket_article_types_controller_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Controllers + class CdrTicketArticleTypesControllerPolicy < Controllers::ApplicationControllerPolicy + def index? + true + end + end +end diff --git a/packages/zammad-addon-bridge/src/config/routes/cdr_ticket_article_types.rb b/packages/zammad-addon-bridge/src/config/routes/cdr_ticket_article_types.rb new file mode 100644 index 0000000..9cc0f45 --- /dev/null +++ b/packages/zammad-addon-bridge/src/config/routes/cdr_ticket_article_types.rb @@ -0,0 +1,5 @@ +Zammad::Application.routes.draw do + api_path = Rails.configuration.api_path + + match api_path + '/ticket_article_types', to: 'cdr_ticket_article_types#index', via: :get +end diff --git a/packages/zammad-addon-bridge/src/db/addon/bridge/20250105000003_add_cdr_link_config.rb b/packages/zammad-addon-bridge/src/db/addon/bridge/20250105000003_add_cdr_link_config.rb new file mode 100644 index 0000000..b826492 --- /dev/null +++ b/packages/zammad-addon-bridge/src/db/addon/bridge/20250105000003_add_cdr_link_config.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class AddCdrLinkConfig < ActiveRecord::Migration[5.2] + def self.up + Setting.create_if_not_exists( + title: 'CDR Link', + name: 'cdr_link_config', + area: 'Integration::CDRLink', + description: 'Defines the CDR Link integration config.', + options: {}, + state: { items: [] }, + frontend: false, + preferences: { + prio: 2, + permission: ['admin.integration'], + } + ) + + # Update the existing allowed_channels setting to use admin.integration permission + setting = Setting.find_by(name: 'cdr_link_allowed_channels') + if setting + setting.preferences = { + permission: ['admin.integration'], + } + setting.save! + end + end + + def self.down + Setting.find_by(name: 'cdr_link_config')&.destroy + + # Restore original permission if rolling back + setting = Setting.find_by(name: 'cdr_link_allowed_channels') + if setting + setting.preferences = { + permission: ['admin'], + } + setting.save! + end + end +end