All files / src/flow/nodes split_by_webhook.ts

90.1% Statements 264/293
72.09% Branches 62/86
80% Functions 8/10
90.1% Lines 264/293

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294132x 132x 132x   132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 3x 3x 3x 3x 3x 4x   3x 3x 3x 3x 3x 3x 3x 5x 5x 5x 3x 5x 2x 132x 2x 2x 2x 2x 2x 1x 8x 8x 8x 8x 8x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 3x 3x 3x 3x 3x 3x   3x 3x 3x 2x 3x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 3x 3x 3x 3x 3x 3x 3x 3x 3x 2x 2x 2x 2x 2x 3x 1x 1x 1x 1x 1x 1x 1x         1x           1x 3x 132x 132x 132x 132x 132x 132x 132x 132x 132x 12x 12x 12x     12x 132x 132x 132x 132x 132x 132x 132x 132x 12x 12x 12x 132x                               132x 8x 8x 8x 13x 13x 13x 13x 13x 13x 13x 13x 13x 8x 8x 13x 8x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 13x 11x 132x 132x 132x 132x 132x 132x 132x 139x 139x 139x 132x 132x 132x 137x 132x 132x 132x 132x 132x 135x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 132x 133x 133x 133x 133x 133x 132x 132x 132x 132x 135x 132x 132x 132x 132x 132x  
import { ACTION_GROUPS, FormData, NodeConfig, FlowTypes } from '../types';
import { CallWebhook, Node } from '../../store/flow-definition';
import { generateUUID, createSuccessFailureRouter } from '../../utils';
import { html } from 'lit';
import { resultNameField } from './shared';
import { renderClamped, renderHighlightedText } from '../utils';
 
// Default headers as {key, value} arrays (format used by key-value editor)
const defaultGetHeaders = [{ key: 'Accept', value: 'application/json' }];
 
const defaultPostHeaders = [
  { key: 'Accept', value: 'application/json' },
  { key: 'Content-Type', value: 'application/json' }
];
 
function headersMatchDefaults(headers: any[]): boolean {
  if (!headers || headers.length === 0) return true;
  const filtered = headers.filter(
    (h: any) => (h.key || h.name || '') !== '' || (h.value || '') !== ''
  );
  if (filtered.length === 0) return true;
  for (const defaults of [defaultGetHeaders, defaultPostHeaders]) {
    if (
      filtered.length === defaults.length &&
      filtered.every(
        (h: any, i: number) =>
          (h.key || h.name) === defaults[i].key && h.value === defaults[i].value
      )
    ) {
      return true;
    }
  }
  return false;
}
 
function getDefaultHeaders(method: string): { key: string; value: string }[] {
  if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
    return defaultPostHeaders.map((h) => ({ ...h }));
  }
  return defaultGetHeaders.map((h) => ({ ...h }));
}
 
// Default headers as Record format (for toFormData, before processFormDataForEditing converts them)
function getDefaultHeadersRecord(method: string): Record<string, string> {
  if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
    return { Accept: 'application/json', 'Content-Type': 'application/json' };
  }
  return { Accept: 'application/json' };
}
 
const defaultPost = `@(json(object(
  "contact", object(
    "uuid", contact.uuid, 
    "name", contact.name, 
    "urn", contact.urn
  ),
  "flow", object(
    "uuid", run.flow.uuid, 
    "name", run.flow.name
  ),
  "results", foreach_value(results, extract_object, "value", "category")
)))`;
 
export const split_by_webhook: NodeConfig = {
  type: 'split_by_webhook',
  name: 'Call Webhook',
  group: ACTION_GROUPS.services,
  flowTypes: [FlowTypes.VOICE, FlowTypes.MESSAGE, FlowTypes.BACKGROUND],
  showAsAction: true,
  form: {
    method: {
      type: 'select',
      required: true,
      options: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'],
      maxWidth: '120px',
      searchable: false
    },
    url: {
      type: 'text',
      required: true,
      evaluated: true,
      maxLength: 8192,
      placeholder: 'https://example.com/webhook'
    },
    headers: {
      type: 'key-value',
      sortable: true,
      keyPlaceholder: 'Header name',
      valuePlaceholder: 'Header value',
      maxItems: 100,
      keyMaxLength: 100,
      valueMaxLength: 8192,
      minRows: 0,
      dependsOn: ['method'],
      computeValue: (
        values: Record<string, any>,
        currentValue: any,
        _originalValues?: Record<string, any>
      ) => {
        const method =
          Array.isArray(values.method) && values.method.length > 0
            ? values.method[0].value || values.method[0].name
            : values.method;
 
        if (headersMatchDefaults(currentValue)) {
          return getDefaultHeaders(method);
        }
        return currentValue;
      }
    },
    result_name: resultNameField,
    body: {
      type: 'textarea',
      evaluated: true,
      maxLength: 20000,
      placeholder: 'Request body content (JSON, XML, etc.)',
      minHeight: 200,
      dependsOn: ['method'],
      computeValue: (
        values: Record<string, any>,
        currentValue: any,
        originalValues?: Record<string, any>
      ) => {
        // Check if method is POST (handle both string and select object formats)
        const method =
          Array.isArray(values.method) && values.method.length > 0
            ? values.method[0].value || values.method[0].name
            : values.method;
 
        if (method === 'POST') {
          // For POST, provide the template if body is empty or was never set by user
          if (!currentValue || currentValue.trim() === '') {
            return defaultPost;
          }
        } else {
          // For non-POST methods, clear the body if it was auto-generated or empty
          // Check if the original body was empty (user never specified a body)
          const originalBody = originalValues?.body || '';
          const isOriginallyEmpty = !originalBody || originalBody.trim() === '';
 
          // Clear if: originally empty, contains default template, or is currently empty
          if (
            isOriginallyEmpty ||
            !currentValue ||
            currentValue.trim() === '' ||
            currentValue.trim() === defaultPost.trim()
          ) {
            return '';
          }
        }
 
        return currentValue; // Keep existing value if user has customized it
      }
    }
  },
  layout: [
    { type: 'row', items: ['method', 'url'] },
    {
      type: 'accordion',
      sections: [
        {
          label: 'Headers',
          collapsed: true,
          getValueCount: (formData: FormData) => {
            return formData.headers?.length || 0;
          },
          items: ['headers']
        },
        {
          label: 'Body',
          collapsed: true,
          getValueCount: (formData: FormData) => {
            return !!(
              formData.body &&
              formData.body.trim() !== '' &&
              formData.body !== defaultPost
            );
          },
          items: ['body']
        }
      ]
    },
    'result_name'
  ],
  render: (node: Node) => {
    const callWebhookAction = node.actions?.find(
      (action) => action.type === 'call_webhook'
    ) as CallWebhook;
    const url = callWebhookAction?.url || 'Configure webhook';
    return html` <div class="body">
      ${renderClamped(renderHighlightedText(url, true), url)}
    </div>`;
  },
  toFormData: (node: Node) => {
    // Extract data from the existing node structure
    const callWebhookAction = node.actions?.find(
      (action) => action.type === 'call_webhook'
    ) as any;

    return {
      uuid: node.uuid,
      method: callWebhookAction?.method || 'GET',
      url: callWebhookAction?.url || '',
      headers:
        callWebhookAction?.headers ||
        getDefaultHeadersRecord(callWebhookAction?.method || 'GET'),
      body: callWebhookAction?.body || '',
      // Legacy webhooks store result_name on the action; modern ones store it
      // on the router. Prefer the action so legacy nodes round-trip correctly.
      result_name:
        callWebhookAction?.result_name || node.router?.result_name || ''
    };
  },
  fromFormData: (formData: FormData, originalNode: Node): Node => {
    // Get method selection
    const methodSelection =
      Array.isArray(formData.method) && formData.method.length > 0
        ? formData.method[0]
        : { value: 'GET', name: 'GET' };
 
    // Find existing call_webhook action to preserve its UUID
    const existingCallWebhookAction = originalNode.actions?.find(
      (action) => action.type === 'call_webhook'
    ) as CallWebhook | undefined;
    const callWebhookUuid = existingCallWebhookAction?.uuid || generateUUID();
 
    // Legacy webhooks keep the result_name on the action rather than the
    // router. Detect them by the presence of result_name on the existing
    // action so they continue to round-trip in their original format instead
    // of being silently migrated (which would change how results are
    // referenced downstream of the flow).
    const isLegacyResultName = !!existingCallWebhookAction?.result_name;
    const trimmedResultName = formData.result_name?.trim() || '';
 
    // Create call_webhook action
    const callWebhookAction: CallWebhook = {
      type: 'call_webhook',
      uuid: callWebhookUuid,
      method: methodSelection.value,
      url: formData.url || '',
      headers: formData.headers || [],
      body: formData.body || ''
    };
 
    // Preserve the legacy placement: result_name stays on the action.
    if (isLegacyResultName && trimmedResultName !== '') {
      callWebhookAction.result_name = trimmedResultName;
    }
 
    // Create categories and exits for Success and Failure
    const existingCategories = originalNode.router?.categories || [];
    const existingExits = originalNode.exits || [];
    const existingCases = originalNode.router?.cases || [];
 
    const { router, exits } = createSuccessFailureRouter(
      // default() guards against @webhook being null (e.g. the call was
      // skipped because the request exceeded the engine's size limit) which
      // would otherwise produce lookup errors when the router evaluates
      '@(default(webhook.status, 0))',
      {
        type: 'has_number_between',
        arguments: ['200', '299']
      },
      existingCategories,
      existingExits,
      existingCases
    );
 
    // Build final router with result_name
    const finalRouter: any = {
      ...router
    };
 
    // Modern webhooks store result_name on the router. For legacy webhooks the
    // result_name lives on the action (set above), so we leave it off the
    // router to avoid storing it in both places.
    if (!isLegacyResultName && trimmedResultName !== '') {
      finalRouter.result_name = trimmedResultName;
    }
 
    // Return the complete node
    return {
      uuid: originalNode.uuid,
      actions: [callWebhookAction],
      router: finalRouter,
      exits: exits
    };
  },
 
  // Localization support for categories
  localizable: 'categories',
  nonTranslatableCategories: 'all'
};