File

libs/core-forms/src/lib/backend-integration-plugin/backend-integration-plugin.ts

Index

Methods

Constructor

constructor()

Methods

registerActions
registerActions()
Returns : void


type: plugin title: backend-integration-plugin description: This plugin automatically fetches the Turnstile sitekey, transforms the input data of a dynamic form and sends it to the CoreForms backend. In addition, it allows to include the dynamic form data in the next defined page(s) URL(s) as query-string parameter(s). runtimeCompatible: true

⚠️ This plugin is for internal usage only!

How to use the CoreForms backend plugin?

  1. In your pfe.jsonc file, add the CORE_FORMS_BACKEND_INTEGRATION to the onPageLeaveActions of the page where the data should be sent to the backend. There is no action provided for catching the sitekey of the Turnstile. This is done automatically by the plugin as soon as it has been initialized. The sitekey will be stored in the $._taly.turnstileSitekey key of the PFE state. If the request for the sitekey fails, turnstileSitekey will be set to an empty string.
Example :
"pages": [
  {
    "pageId": "some-page-id",
    "onPageLeaveActions": [
      {
        "type": "CORE_FORMS_BACKEND_INTEGRATION"
      }
    ]
  }
]
  1. Add the @allianz/taly-core-forms package to the libraries array of the pages.jsonc file
Example :
"libraries": [
  {
    "package": "@allianz/taly-core-forms",
    "version": "CURRENT_VERSION"
  }
]
  1. Add the BackendIntegrationPluginModule with the options below to the plugins array of the pages.jsonc file. talyPrefix describes how the dynamic form ids have to start to be recognized by the plugin.
Example :
"plugins": [
  {
    "package": "@allianz/taly-core-forms",
    "modules": [
      {
        "name": "BackendIntegrationPluginModule",
        "options": {
          "formId": "1740391937943_v3",
          "tenant": "sandbox",
          "site": "coreforms",
          "instance": "emeaprd",
          "talyTurnstileFormfieldId": "turnstile-token",
          "talyPrefix": "core-forms-"
        }
      }
    ]
  }
],
  1. Ensure that exactly one Turnstile custom component is added to your form with the id (talyTurnstileFormfieldId) you specified in step 2. This Turnstile custom component has to be a child of a dynamic form with an id starting with the talyPrefix you defined in step 2.

Visibility Hash Generation

The backend integration plugin can generate a visibility hash to uniquely represent the set of visible fields for any given state in conditional, multi-page forms. This feature is designed to support backend workflows that require deterministic identification of form variations.

Why use a visibility hash?

The hash is used to support the backend validation of complex form with conditional fields. The backend uses the hash to uniquely identify form variations and apply the correct validation schema.

How does it work?

The plugin collects all visible field IDs (excluding the Turnstile field), sorts and serializes them, and computes a truncated SHA-256 hash (first 12 hex characters). This hash is unique to the specific set and order of visible fields for a given state. When enabled, the hash is included in the payload as visibilityHash.

How to enable?

Add "generateVisibilityHash": true to your plugin options:

Example :
{
  "name": "BackendIntegrationPluginModule",
  "options": {
    // ...other options...
    "generateVisibilityHash": true
  }
}

Append Data to the next defined page(s) URL(s)

Using the appendFormDataToNextPageUrl option it is possible to append the form data to the next defined page(s) URL(s) as query-string parameters.

Behaviour

The plugin will update each nextPageId contained in the nextOptionList of the page where the CORE_FORMS_BACKEND_INTEGRATION action is set.

Notes / limitations

  • The existing query parameters will be preserved.
  • Only form fields will be included (visibility hash and turnstile token are excluded)

Example configuration

Example :
{
  "pagesConfig": {
    "libraries": [
      {
        "package": "@allianz/taly-core",
        "version": ""
      },
      {
        "package": "@allianz/taly-core-forms",
        "version": ""
      }
    ],
    "plugins": [
      {
        "package": "@allianz/taly-core-forms",
        "modules": [
          {
            "name": "BackendIntegrationPluginModule",
            "options": {
              "formId": "123456789",
              "tenant": "platform",
              "site": "premiumcomponents",
              "instance": "localhost",
              "talyTurnstileFormfieldId": "turnstile-token_1123456789",
              "talyPrefix": "core-forms-",
              "appendFormDataToNextPageUrl": true
            }
          },
          {
            "name": "TurnstileComponentPluginModule"
          }
        ]
      }
    ],
    "pages": [
      {
        "id": "form-page-112345",
        "blocks": [
          {
            "id": "core-forms-172345",
            "form": {
              "fields": [
                {
                  "id": "email",
                  "type": "INPUT",
                  "label": "E-Mail Address",
                  "validators": [
                    {
                      "type": "PATTERN",
                      "pattern": "^([-!#-'*+/-9=?A-Z^-~]+(\\.[-!#-'*+/-9=?A-Z^-~]+)*|\"([]!#-[^-~ \t]|(\\\\[\t -~]))+\")@[0-9A-Za-z]([0-9A-Za-z-]{0,61}[0-9A-Za-z])?(\\.[0-9A-Za-z]([0-9A-Za-z-]{0,61}[0-9A-Za-z])?)+$",
                      "errorMessage": "Please enter a valid email address"
                    },
                    {
                      "type": "REQUIRED",
                      "errorMessage": "This field is required: E-Mail Address"
                    }
                  ]
                },
                {
                  "id": "turnstile-token_1758712460467",
                  "type": "CUSTOM_COMPONENT",
                  "name": "TurnstileComponent",
                  "config": {
                    "sitekey": "0x4AAAAAAA0ArZGd9PTlHgW9"
                  }
                }
              ]
            }
          }
        ],
        "pageData": {
          "pageActionConfig": {
            "backButtonHidden": true,
            "nextButtonLabel": "Submit Form"
          }
        }
      }
    ],
    "frame": {
      "chromeless": true,
      "navigation": {
        "sections": []
      }
    }
  },
  "pfeConfig": {
    "navConfiguration": {
      "pages": [
        {
          "pageId": "form-page-1758712460467",
          "nextOptionList": [
            {
              "nextPageId": "https://example-externalpage.com/simple-page.html",
              "external": true
            }
          ],
          "onPageLeaveActions": [
            {
              "type": "CORE_FORMS_BACKEND_INTEGRATION"
            }
          ]
        }
      ]
    }
  }
}

Example Configuration

The snippet below shows an example pages.jsonc configuration of the backend and turnstile plugin in combination with a dynamic form with one input field. It also appends the form data to the next page ID as query-string parameters.

Example :
  "libraries": [
    {
      "package": "@allianz/taly-core-forms",
      "version": ""
    }
  ],
  "plugins": [
    {
      "package": "@allianz/taly-core-forms",
      "modules": [
        {
          "name": "BackendIntegrationPluginModule",
          "options": {
            "formId": "123456789",
            "tenant": "sandbox",
            "site": "coreforms",
            "instance": "emeaprd",
            "talyTurnstileFormfieldId": "turnstile-token",
            "talyPrefix": "core-forms-",
            "appendFormDataToNextPageUrl": true,
          }
        }
      ]
    },
    {
      "package": "@allianz/taly-core-forms",
      "modules": [
        {
          "name": "TurnstileComponentPluginModule"
        }
      ]
    }
  ],
  "pages": [
    {
      "id": "first-page",
      "blocks": [
        {
          "id": "core-forms-my-dynamic-form1",
          "form": {
            "layout": {
              "type": "ONE_COLUMN"
            },
            "fields": [
              {
                "id": "street",
                "type": "INPUT",
                "label": "street",
                "inputType": "text"
              },
              {
                "type": "CUSTOM_COMPONENT",
                "name": "TurnstileComponent",
                "id": "turnstile-token",
                "config": {
                  "sitekey": "1x00000000000000000000AA"
                }
              }
            ]
          }
        }
      ],
    }
  ]

import {
  PfeActionsService,
  PfeBusinessService,
  PfeNavigationService,
  PfeServiceActivatorService
} from '@allianz/ngx-pfe';
import { BFF_BASE_URL_TOKEN } from '@allianz/taly-core';
import { isDynamicFormBbConfig } from '@allianz/taly-core/schemas';
import * as windowUtils from '../utils/window-utils';
import { PfeRuntimeConfigService } from '@allianz/taly-pfe-connector';
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { effect, inject, Injectable, Signal } from '@angular/core';
import {
  CORE_FORMS_BACKEND_INTEGRATION_ACTION_TYPE,
  CORE_FORMS_BACKEND_INTEGRATION_PLUGIN_OPTIONS,
  CoreFormsBackendIntegrationPluginOptions
} from './backend-integration-plugin.module';
import { TrackingPlugin } from '../tracking-plugin/tracking-plugin';

@Injectable()
export class BackendIntegrationPlugin {
  private readonly pfeActionService = inject(PfeActionsService);
  private readonly pfeBusinessService = inject(PfeBusinessService);
  private readonly runtimeConfigService = inject(PfeRuntimeConfigService);
  private readonly pfeServiceActivatorService = inject(PfeServiceActivatorService);
  private readonly trackingPlugin = inject(TrackingPlugin, { optional: true });
  private readonly navigationService = inject(PfeNavigationService);
  private readonly http = inject(HttpClient);
  private options = inject<Signal<CoreFormsBackendIntegrationPluginOptions>>(
    CORE_FORMS_BACKEND_INTEGRATION_PLUGIN_OPTIONS
  );
  private readonly requiredStringProperties: Array<keyof CoreFormsBackendIntegrationPluginOptions> =
    ['formId', 'tenant', 'site', 'instance', 'talyTurnstileFormfieldId', 'talyPrefix'];
  private readonly coreFormsBaseUrl = inject(BFF_BASE_URL_TOKEN);

  constructor() {
    effect(async () => {
      const isCoreFormsBackendIntegrationPluginOptions = (
        obj: unknown
      ): obj is CoreFormsBackendIntegrationPluginOptions => {
        if (!obj || typeof obj !== 'object') {
          return false;
        }
        const options = obj as Record<string, unknown>;
        return this.requiredStringProperties.every(
          (prop) => prop in options && typeof options[prop] === 'string'
        );
      };

      if (!this.options() || !Object.keys(this.options()).length) {
        return;
      }

      if (isCoreFormsBackendIntegrationPluginOptions(this.options())) {
        await this.fetchTurnstileSitekey();
      } else {
        console.error(
          `Invalid options format for BackendIntegrationPlugin. Please provide the following plugin options: ${this.requiredStringProperties.join(
            ', '
          )}.`
        );
      }
    });

    effect(() => {
      this.trackingPlugin?.onFormInitialized(this.getRelevantFormFields());
    });
  }

  registerActions() {
    this.pfeActionService.registerAction(
      CORE_FORMS_BACKEND_INTEGRATION_ACTION_TYPE,
      this.runCoreFormsBackendIntegration.bind(this)
    );
  }

  private async runCoreFormsBackendIntegration(): Promise<void> {
    const fields = this.getCoreFormsState(this.options().talyPrefix);

    let visibilityHash: string | undefined;
    if (this.options().generateVisibilityHash) {
      visibilityHash = await this.computeVisibilityHash();
    }

    const turnstileToken = fields?.[this.options().talyTurnstileFormfieldId];

    if (!turnstileToken || typeof turnstileToken !== 'string') {
      this.trackingPlugin?.onPreSubmitError('Could not find the turnstile token in the form');
      throw new Error('Could not find the turnstile token in the form');
    }

    delete fields[this.options().talyTurnstileFormfieldId];

    await this.postFormData(turnstileToken, fields, visibilityHash);

    if (this.options().appendFormDataToNextPageUrl) {
      this.appendDataUrl(fields);
    }
  }

  private postFormData(
    turnstileToken: string,
    fieldsForBackend: Record<string, unknown>,
    visibilityHash?: string
  ): Promise<void> {
    const { formId, tenant, site, instance } = this.options();
    const referrer = windowUtils.getHref();
    const data = JSON.stringify({
      version: 1,
      formId,
      tenant,
      site,
      visibilityHash,
      instance,
      referrer,
      fields: fieldsForBackend,
      turnstileCaptcha: {
        cfturnstileresponse: turnstileToken
      },
      captchaType: 'turnstile'
    });
    this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(true);

    return new Promise<void>((resolve) => {
      this.http.post<{ message: string }>(this.coreFormsBaseUrl(), data).subscribe({
        next: (response) => {
          this.trackingPlugin?.onSubmitSuccess();
          this.pfeBusinessService.storeValueByExpression(
            '$.coreFormSubmissionSuccessful',
            response.message === 'successful'
          );
          this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(false);
          resolve();
        },
        error: (error) => {
          this.trackingPlugin?.onSubmitError(this.extractErrorCode(error), {
            status: error?.status,
            message: error?.message
          });
          this.pfeBusinessService.storeValueByExpression('$.coreFormSubmissionSuccessful', false);
          this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(false);
          resolve();
        }
      });
    });
  }

  private extractErrorCode(error: unknown) {
    if (error instanceof HttpErrorResponse) {
      return error.headers?.get('x-error-code') ?? String(error.status);
    }
    return null;
  }

  private getCoreFormsState(prefix: string): Record<string, unknown> {
    const pfeState = this.pfeBusinessService.getFullState();

    return Object.entries(pfeState)
      .filter(([formKey]) => formKey.startsWith(prefix)) // Filter keys that start with the prefix
      .filter(([, formfields]) => typeof formfields === 'object' && formfields !== null)
      .reduce<Record<string, unknown>>((relevantFields, [, formfields]) => {
        Object.entries(formfields).forEach(([formfieldKey, formfieldValue]) => {
          if (relevantFields[formfieldKey]) {
            console.warn(
              `The form field with id "${formfieldKey}" is present in more than one form with prefix "${prefix}". Please provide a unique id for each field.`
            );
            return;
          }
          if (formfieldValue !== null && formfieldValue !== '') {
            relevantFields[formfieldKey] = formfieldValue;
          }
        });
        return relevantFields;
      }, {});
  }

  private fetchTurnstileSitekey(): Promise<void> {
    if (!this.coreFormsBaseUrl()) {
      return Promise.reject(
        'Base URL is not defined. Please forward `{"bffBaseUrl": ""}` via `runtime-app-config` input'
      );
    }

    const requestUrl = [
      this.coreFormsBaseUrl(),
      this.options().instance,
      this.options().tenant,
      this.options().site,
      this.options().formId
    ].join('/');

    const referrer = windowUtils.getHref();
    const relevantFormFieldIds = this.getRelevantFormFields().map((formfield) => formfield.id);
    const params = new HttpParams()
      .set('referrer', referrer)
      .set('fields', relevantFormFieldIds.join(','));
    this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(true);

    return new Promise<void>((resolve) => {
      this.http.head(requestUrl, { params, observe: 'response' }).subscribe({
        next: (response) => {
          const sitekey = response.headers.get('x-cf-turnstile-sitekey');
          this.pfeBusinessService.storeValueByExpression('$._taly.turnstileSitekey', sitekey);
          this.pfeBusinessService.storeValueByExpression('$._taly.turnstileSitekeyError', null);
          this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(false);
          resolve();
        },
        error: (error) => {
          this.trackingPlugin?.onFormLoadError(this.extractErrorCode(error), {
            status: error?.status,
            message: error?.message
          });
          this.pfeBusinessService.storeValueByExpression('$._taly.turnstileSitekey', '');
          this.pfeBusinessService.storeValueByExpression('$._taly.turnstileSitekeyError', error);
          console.error(error);
          this.pfeServiceActivatorService.serviceActivatorCallInProgress$.next(false);
          resolve();
        }
      });
    });
  }

  private getRelevantFormFields() {
    const { pages } = this.runtimeConfigService.pagesConfig();
    const { talyTurnstileFormfieldId, talyPrefix } = this.options();

    const formFields = pages
      .flatMap((page) => page?.blocks)
      .filter(isDynamicFormBbConfig)
      .filter((block) => block?.id.startsWith(talyPrefix))
      .flatMap((block) => block?.form?.fields);

    return formFields
      .filter((formfield) => formfield?.id !== talyTurnstileFormfieldId)
      .filter(
        (formfield) =>
          formfield.type !== 'LINE_BREAK' &&
          formfield.type !== 'HEADLINE' &&
          formfield.type !== 'PARAGRAPH' &&
          formfield.type !== 'NOTIFICATION' &&
          !(formfield.type === 'CUSTOM_COMPONENT' && formfield.name === 'SummaryComponent')
      );
  }

  private getFormFieldIdsFromState(prefix: string): string[] {
    const pfeState = this.pfeBusinessService.getFullState();

    const fieldIds = Object.entries(pfeState)
      .filter(([formKey]) => formKey.startsWith(prefix))
      .filter(([, formfields]) => typeof formfields === 'object' && formfields !== null)
      .flatMap(([, formfields]) => Object.keys(formfields));

    return [...new Set(fieldIds)];
  }

  private async computeVisibilityHash(): Promise<string> {
    const { talyTurnstileFormfieldId, talyPrefix } = this.options();

    const fieldIds = this.getFormFieldIdsFromState(talyPrefix).filter(
      (fieldId) => fieldId !== talyTurnstileFormfieldId
    );

    const sortedFieldIds = fieldIds.sort();
    const serializedFieldIds = JSON.stringify(sortedFieldIds);

    // Compute SHA-256 hash
    const textEncoder = new TextEncoder();
    const encodedData = textEncoder.encode(serializedFieldIds);
    const hashBuffer = await crypto.subtle.digest('SHA-256', encodedData);

    // Convert to hex string
    const hashArray = Array.from(new Uint8Array(hashBuffer));
    const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join('');
    const truncatedHash = hashHex.substring(0, 12);

    return truncatedHash;
  }

  private appendDataUrl(formData: Record<string, unknown>) {
    const currentPageId = this.navigationService.currentPageId$.value;
    const pfeConfig = this.runtimeConfigService.pfeConfig();
    const currentPage = pfeConfig.navConfiguration?.pages?.find((p) => p.pageId === currentPageId);
    if (!currentPage?.nextOptionList?.length) {
      console.warn('BackendIntegrationPlugin: No nextOptionList found for the current page');
      return;
    }

    currentPage.nextOptionList.forEach((nextOptionListItem) => {
      if (!nextOptionListItem?.nextPageId || !nextOptionListItem.external) {
        return;
      }

      const redirectionUrl = new URL(nextOptionListItem.nextPageId);
      Object.entries(formData).forEach(([key, value]) => {
        const serializedValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
        redirectionUrl.searchParams.set(key, serializedValue);
      });
      nextOptionListItem.nextPageId = redirectionUrl.toString();
    });
  }
}

results matching ""

    No results matching ""