feat: post application forms to MyEP with label-keyed extra values

Forms built in the backend form editor use generated element identifiers
(text-1, multicheckbox-2, ...) that carry no meaning and cannot be renamed
without breaking the other finishers configured on the form. ApplicationApiFinisher
therefore sends two halves: a small mapped core (firstName, lastName, email, ...)
that a developer configures per form and MyEP can rely on, plus every remaining
submitted value keyed by the element label for a human to read.

Each submission also carries where it came from: the form identifier and its
editor-given name, plus the uid and canonical URL of the page it was submitted
from - the same definition is embedded on several pages, so the page is what
distinguishes them. The URL is generated through the site router in the visitor's
language, like AddReferrerFinisher does it.

Labels come from the form definition rather than TranslationService, so the keys
do not shift with the site language. Values are normalized for JSON: dates to
Y-m-d, uploads to the file name, multi-value elements to a list, empty values
dropped, unsupported objects skipped with a log warning.

Like BpnApiFinisher the request failure is only logged, so a MyEP outage never
costs the application mail.

The endpoint (POST applications) does not exist in MyEP yet; the contract handed
over to that team lives in .ddev/plans/MYEP-APPLICATION-ENDPOINT.md. Until it is
built the finisher is registered but attached to no form.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-22 13:42:07 +02:00
co-authored by Claude Opus 5
parent 30e531497d
commit e52f7dcff8
6 changed files with 249 additions and 6 deletions
@@ -105,6 +105,20 @@ class ApiClient implements LoggerAwareInterface
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'contactform'); $this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'contactform');
} }
/**
* @throws ApiException
*/
public function submitApplication(array $applicationData): void
{
$response = $this->request('POST', 'applications', [
'json' => $applicationData,
]);
// A conflict means the submission is already known, which is the normal answer to a
// double-clicked submit button and not worth reporting.
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT, Response::HTTP_CONFLICT], 'POST', 'applications');
}
/** /**
* @throws ApiException * @throws ApiException
*/ */
@@ -4,25 +4,43 @@ namespace EP\EpTheme\Form;
use TYPO3\CMS\Core\Log\LogManager; use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher; use TYPO3\CMS\Form\Domain\Finishers\AbstractFinisher;
abstract class AbstractMappedFinisher extends AbstractFinisher abstract class AbstractMappedFinisher extends AbstractFinisher
{ {
protected function buildPayload(): array /**
* Resolves the 'mapping' option into remote name => local form element identifier.
*/
protected function parseMapping(): array
{ {
$values = []; $mappings = [];
$formValues = $this->finisherContext->getFormValues();
$mappingOption = (string)$this->parseOption('mapping'); $mappingOption = (string)$this->parseOption('mapping');
$mappings = GeneralUtility::trimExplode(',', $mappingOption, true);
foreach ($mappings as $mapping) { foreach (GeneralUtility::trimExplode(',', $mappingOption, true) as $mapping) {
$parts = GeneralUtility::trimExplode(':', $mapping, true, 2); $parts = GeneralUtility::trimExplode(':', $mapping, true, 2);
if (2 !== count($parts)) { if (2 !== count($parts)) {
continue; continue;
} }
[$remote, $local] = $parts; [$remote, $local] = $parts;
if ('' === $remote || '' === $local || false === array_key_exists($local, $formValues)) { if ('' === $remote || '' === $local) {
continue;
}
$mappings[$remote] = $local;
}
return $mappings;
}
protected function buildPayload(): array
{
$values = [];
$formValues = $this->finisherContext->getFormValues();
foreach ($this->parseMapping() as $remote => $local) {
if (false === array_key_exists($local, $formValues)) {
continue; continue;
} }
@@ -32,6 +50,95 @@ abstract class AbstractMappedFinisher extends AbstractFinisher
return $values; return $values;
} }
/**
* Every submitted value that the mapping does not already cover, keyed by the element label
* as the editor sees it in the form editor. Forms built in the backend use generated
* identifiers (text-1, singleselect-2, ...) that carry no meaning and cannot be renamed
* without breaking existing finisher options, so the label is the only stable description
* of what was asked.
*
* The label is taken from the form definition, not through TranslationService: a translated
* label would change the payload keys with the site language, and these keys are stored.
*/
protected function buildLabeledValues(): array
{
$formValues = $this->finisherContext->getFormValues();
$skip = array_merge(
array_values($this->parseMapping()),
GeneralUtility::trimExplode(',', (string)$this->parseOption('exclude'), true)
);
// Keyed by identifier in form order, and contains only real form elements - iterating
// this instead of the form values keeps the collection in the order of the form and
// leaves out anything that is not an element the visitor filled in.
$elements = $this->finisherContext->getFormRuntime()->getFormDefinition()->getElements();
$collected = [];
foreach ($elements as $identifier => $element) {
if (true === in_array($identifier, $skip, true) || false === array_key_exists($identifier, $formValues)) {
continue;
}
$value = $this->normalizeValue($formValues[$identifier], $identifier);
if (null === $value || '' === $value || [] === $value) {
continue;
}
$label = trim($element->getLabel());
if ('' === $label) {
$label = $identifier;
}
if (true === array_key_exists($label, $collected)) {
$label .= ' (' . $identifier . ')';
}
$collected[$label] = $value;
}
return $collected;
}
/**
* @param mixed $value
*
* @return mixed null when the value has no JSON representation
*/
protected function normalizeValue($value, string $identifier)
{
if (true === is_array($value)) {
$normalized = [];
foreach ($value as $item) {
$item = $this->normalizeValue($item, $identifier);
if (null !== $item && '' !== $item) {
$normalized[] = $item;
}
}
return $normalized;
}
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d');
}
if ($value instanceof FileReference) {
$resource = $value->getOriginalResource();
return null !== $resource ? $resource->getName() : null;
}
if (true === is_object($value)) {
$this->logWarning('Form value skipped while collecting labeled values: unsupported type.', [
'element' => $identifier,
'class' => get_class($value),
]);
return null;
}
return $value;
}
protected function logWarning(string $message, array $context = []): void protected function logWarning(string $message, array $context = []): void
{ {
GeneralUtility::makeInstance(LogManager::class) GeneralUtility::makeInstance(LogManager::class)
@@ -0,0 +1,83 @@
<?php
namespace EP\EpTheme\Form;
use EP\EpProducts\MyEP\ApiClient;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
class ApplicationApiFinisher extends AbstractMappedFinisher
{
/**
* @var ApiClient
*/
protected $apiClient;
/**
* @param ApiClient $apiClient
*/
public function injectApiClient(ApiClient $apiClient)
{
$this->apiClient = $apiClient;
}
protected function executeInternal()
{
try {
$payload = $this->buildPayload();
if ([] === $payload) {
$this->logWarning('Application API finisher skipped: the mapping produced no values.');
return;
}
$formDefinition = $this->finisherContext->getFormRuntime()->getFormDefinition();
$formLabel = trim($formDefinition->getLabel());
$payload['form'] = $formDefinition->getIdentifier();
// The name the editor gave the form, so the receiving side can label a submission
// without keeping a list of identifiers. Taken from the definition rather than
// through TranslationService: a translated label would differ per site language.
$payload['formLabel'] = '' !== $formLabel ? $formLabel : $formDefinition->getIdentifier();
$payload['submittedAt'] = (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM);
$payload = array_merge($payload, $this->resolvePageContext());
$payload['details'] = $this->buildLabeledValues();
$this->apiClient->submitApplication($payload);
} catch (\Throwable $e) {
$this->logWarning('Application API finisher request failed.', [
'exception' => $e,
]);
}
}
/**
* The page the form was submitted from. The URL is generated through the site router in the
* visitor's language, the same way AddReferrerFinisher does it, so it is the page's canonical
* URL rather than the submit request with its form arguments attached.
*/
private function resolvePageContext(): array
{
if (false === isset($GLOBALS['TSFE']) || 0 === (int)$GLOBALS['TSFE']->id) {
return [];
}
$pageUid = (int)$GLOBALS['TSFE']->id;
$context = ['pageUid' => $pageUid];
try {
$site = GeneralUtility::makeInstance(SiteFinder::class)->getSiteByPageId($pageUid);
$context['pageUrl'] = (string)$site->getRouter()->generateUri($pageUid, [
'_language' => $GLOBALS['TSFE']->getLanguage(),
]);
} catch (\Throwable $e) {
// An unroutable page must not cost the submission, so the uid is sent without a URL.
$this->logWarning('Application API finisher could not resolve the page URL.', [
'pageUid' => $pageUid,
'exception' => $e,
]);
}
return $context;
}
}
@@ -23,6 +23,8 @@ TYPO3:
finishersDefinition: finishersDefinition:
BpnApiFinisher: BpnApiFinisher:
implementationClassName: 'EP\EpTheme\Form\BpnApiFinisher' implementationClassName: 'EP\EpTheme\Form\BpnApiFinisher'
ApplicationApiFinisher:
implementationClassName: 'EP\EpTheme\Form\ApplicationApiFinisher'
WebhookFinisher: WebhookFinisher:
implementationClassName: 'EP\EpTheme\Form\WebhookFinisher' implementationClassName: 'EP\EpTheme\Form\WebhookFinisher'
AddReferrerFinisher: AddReferrerFinisher:
@@ -12,6 +12,9 @@ TYPO3:
1000: 1000:
value: 'BpnApiFinisher' value: 'BpnApiFinisher'
label: 'BusPro Finisher' label: 'BusPro Finisher'
1010:
value: 'ApplicationApiFinisher'
label: 'Application Finisher'
1050: 1050:
value: 'WebhookFinisher' value: 'WebhookFinisher'
label: 'Webhook Finisher' label: 'Webhook Finisher'
@@ -34,6 +37,25 @@ TYPO3:
propertyPath: 'options.mapping' propertyPath: 'options.mapping'
propertyValidators: propertyValidators:
10: 'NotEmpty' 10: 'NotEmpty'
1010:
identifier: 'ApplicationApiFinisher'
editors:
__inheritances:
10: 'TYPO3.CMS.Form.mixins.formElementMixins.BaseCollectionEditorsMixin'
100:
label: 'Application Finisher'
110:
identifier: 'mapping'
templateName: 'Inspector-TextEditor'
label: 'Mapping'
propertyPath: 'options.mapping'
propertyValidators:
10: 'NotEmpty'
120:
identifier: 'exclude'
templateName: 'Inspector-TextEditor'
label: 'Exclude'
propertyPath: 'options.exclude'
1050: 1050:
identifier: 'WebhookFinisher' identifier: 'WebhookFinisher'
editors: editors:
@@ -70,6 +92,13 @@ TYPO3:
predefinedDefaults: predefinedDefaults:
options: options:
mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1' mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1'
ApplicationApiFinisher:
formEditor:
iconIdentifier: 'form-finisher'
predefinedDefaults:
options:
mapping: 'firstName:text-1,lastName:text-2,email:email-1,phone:telephone-1'
exclude: 'captcha,privacypolicyaccepted'
WebhookFinisher: WebhookFinisher:
formEditor: formEditor:
iconIdentifier: 'form-finisher' iconIdentifier: 'form-finisher'
@@ -11,6 +11,14 @@ TYPO3:
options: options:
identifier: 'BpnApiFinisher' identifier: 'BpnApiFinisher'
mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1' mapping: 'lastName:text-1,firstName:text-2,gender:singleselect-1,email:email-1,phone:telephone-1'
ApplicationApiFinisher:
implementationClassName: 'EP\EpTheme\Form\ApplicationApiFinisher'
formEditor:
label: 'Application Finisher'
options:
identifier: 'ApplicationApiFinisher'
mapping: 'firstName:text-1,lastName:text-2,email:email-1,phone:telephone-1'
exclude: 'captcha,privacypolicyaccepted'
WebhookFinisher: WebhookFinisher:
implementationClassName: 'EP\EpTheme\Form\WebhookFinisher' implementationClassName: 'EP\EpTheme\Form\WebhookFinisher'
formEditor: formEditor: