317 lines
13 KiB
PHP
317 lines
13 KiB
PHP
<?php
|
|
/**
|
|
* Submission - the no-loss / no-duplicate control core.
|
|
*
|
|
* The gated INSTANDA create lives in gform_validation (NOT process_feed) so that a
|
|
* failure can re-render the form with every value intact and save no entry. On
|
|
* success, validation passes, the entry saves, and the add-on's process_feed()
|
|
* records the result natively. gform_confirmation then redirects to the quote URL.
|
|
*
|
|
* Guards against duplicates: final-page-only + a once-per-submission lock +
|
|
* an internal dedupe key (INSTANDA has no server-side idempotency).
|
|
*
|
|
* @package ESP\Instanda
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace ESP\Instanda;
|
|
|
|
final class Submission {
|
|
|
|
private const LOCK_TTL = 60; // seconds
|
|
private const RESULT_TTL = 120;
|
|
private const STASH_TTL = 300;
|
|
|
|
/** Wire var key => feed meta key (the mapped GF field id is stored under each). */
|
|
private const FIELD_KEYS = [
|
|
'role', 'vendor_type', 'performer_type', 'activity_type', 'location_state',
|
|
'max_attendance', 'event_start', 'event_end', 'customer_email',
|
|
];
|
|
|
|
private ?string $pending_message = null;
|
|
|
|
/** Request-scoped dedupe of the just-succeeded submission, for process_feed/entry-created. */
|
|
private static ?string $current_dedupe = null;
|
|
|
|
public static function current_dedupe(): ?string {
|
|
return self::$current_dedupe;
|
|
}
|
|
|
|
/* ----------------------------------------------------------- gform_validation */
|
|
|
|
/** @param array{is_valid:bool,form:array} $validation_result */
|
|
public function validate(array $validation_result): array {
|
|
$form = $validation_result['form'];
|
|
|
|
$feed = $this->active_feed($form);
|
|
if ($feed === null) {
|
|
return $validation_result; // GATE 1 - form has no INSTANDA feed
|
|
}
|
|
if (empty($validation_result['is_valid'])) {
|
|
return $validation_result; // GATE 2 - anti-spam / native validation already failed
|
|
}
|
|
if (!$this->is_last_page($form)) {
|
|
return $validation_result; // GATE 3 - only act on the final page
|
|
}
|
|
|
|
$token = $this->submission_token($form);
|
|
if (!$this->acquire_lock($token)) {
|
|
return $this->replay($token, $validation_result); // GATE 4 - duplicate fire: replay cached outcome
|
|
}
|
|
|
|
$dedupe = $this->dedupe_key($token);
|
|
$store = new Transaction_Store();
|
|
|
|
// Own double-fire guard: a prior attempt for this submission already delivered.
|
|
$existing = $store->find_by_dedupe($dedupe);
|
|
if ($existing !== null && $existing->status === Tx_Status::Delivered->value) {
|
|
$this->stash_success($dedupe, (string) $existing->quote_ref, $existing->quote_url);
|
|
return $this->remember($token, $validation_result, true);
|
|
}
|
|
|
|
$values = $this->extract_values($form, $feed);
|
|
$mapper = new Field_Mapper();
|
|
$tz = wp_timezone_string();
|
|
|
|
$errors = $mapper->validate($values, $tz);
|
|
if ($errors) {
|
|
$this->apply_field_errors($validation_result, $form, $feed, $errors);
|
|
return $this->remember($token, $validation_result, false);
|
|
}
|
|
|
|
$body = $mapper->to_payload($values, $tz);
|
|
|
|
// Write-ahead: persist BEFORE the HTTP call (redacted body carries no secrets).
|
|
$store_enabled = get_option('espinstanda_storage_enabled', '1') === '1';
|
|
$tx_id = 0;
|
|
if ($store_enabled) {
|
|
$tx = new Transaction();
|
|
$tx->form_id = (int) $form['id'];
|
|
$tx->environment = Config::environment()->value;
|
|
$tx->dedupe_key = $dedupe;
|
|
$tx->customer_email = (string) ($values['customer_email'] ?? '');
|
|
$tx->role_label = (string) ($values['role'] ?? '');
|
|
$tx->redacted_payload = (string) wp_json_encode($body);
|
|
$tx_id = $store->create_pending($tx);
|
|
}
|
|
|
|
$result = (new Api_Client())->start_quote($body, $dedupe);
|
|
|
|
if ($result->ok && $result->quote_ref !== null) {
|
|
if ($tx_id) {
|
|
$store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status);
|
|
}
|
|
$this->stash_success($dedupe, $result->quote_ref, $result->url_single_use);
|
|
return $this->remember($token, $validation_result, true);
|
|
}
|
|
|
|
// Failure / ambiguous timeout - input preserved, no entry saved.
|
|
if ($tx_id) {
|
|
if ($result->is_ambiguous) {
|
|
$store->mark_ambiguous($tx_id, $result->http_status, $result->error_string());
|
|
} else {
|
|
$store->mark_failed($tx_id, $result->http_status, $result->response_excerpt, $result->error_string());
|
|
}
|
|
$stored = $store->find($tx_id);
|
|
if ($stored !== null) {
|
|
(new Notifier())->validation_failure_alert($stored, $result);
|
|
}
|
|
}
|
|
Failure_Surface::bump();
|
|
|
|
$this->pending_message = $this->friendly_failure_message($result);
|
|
$invalid = $this->mark_form_invalid($validation_result);
|
|
return $this->remember($token, $invalid, false);
|
|
}
|
|
|
|
/** gform_validation_message filter - surface our form-level failure message. */
|
|
public function validation_message(string $message, array $form): string {
|
|
return $this->pending_message !== null ? esc_html($this->pending_message) : $message;
|
|
}
|
|
|
|
/* ----------------------------------------------------------- gform_confirmation */
|
|
|
|
public function confirmation(mixed $confirmation, array $form, array $entry, bool $ajax): mixed {
|
|
if ($this->active_feed($form) === null) {
|
|
return $confirmation;
|
|
}
|
|
$url = (string) gform_get_meta((int) $entry['id'], 'instanda_quote_url');
|
|
if ($url === '') {
|
|
$stash = $this->read_stash_for_entry($form);
|
|
$url = $stash['url'] ?? '';
|
|
}
|
|
if ($url !== '') {
|
|
return ['redirect' => $url];
|
|
}
|
|
return $confirmation; // defensive: fall through to the form's default confirmation
|
|
}
|
|
|
|
/* ----------------------------------------------------------- success stash */
|
|
|
|
/** Stash by dedupe so process_feed (possibly async) can record without re-calling the API. */
|
|
private function stash_success(string $dedupe, string $quote_ref, ?string $url): void {
|
|
self::$current_dedupe = $dedupe;
|
|
set_transient('espinstanda_stash_' . md5($dedupe), [
|
|
'quote_ref' => $quote_ref,
|
|
'url' => (string) $url,
|
|
], self::STASH_TTL);
|
|
}
|
|
|
|
public function read_stash(string $dedupe): ?array {
|
|
$v = get_transient('espinstanda_stash_' . md5($dedupe));
|
|
return is_array($v) ? $v : null;
|
|
}
|
|
|
|
private function read_stash_for_entry(array $form): array {
|
|
// Best-effort: the confirmation runs in the same request as a fresh success.
|
|
$token = $this->submission_token($form);
|
|
$dedupe = $this->dedupe_key($token);
|
|
return $this->read_stash($dedupe) ?? [];
|
|
}
|
|
|
|
/* ----------------------------------------------------------- feed + values */
|
|
|
|
private function active_feed(array $form): ?array {
|
|
$addon = Instanda_AddOn::get_instance();
|
|
$feeds = $addon->get_feeds((int) $form['id']);
|
|
foreach ($feeds as $feed) {
|
|
if (!empty($feed['is_active'])) {
|
|
return $feed;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** @return array<string,string> intent key => submitted value */
|
|
private function extract_values(array $form, array $feed): array {
|
|
$meta = $feed['meta'] ?? [];
|
|
$values = [];
|
|
foreach (self::FIELD_KEYS as $key) {
|
|
$field_id = (string) ($meta[$key] ?? '');
|
|
$values[$key] = $field_id !== '' ? $this->submitted_value($form, $field_id) : '';
|
|
}
|
|
return $values;
|
|
}
|
|
|
|
/** Read a submitted field value via GF's API (never raw $_POST). @verify-on-site */
|
|
private function submitted_value(array $form, string $field_id): string {
|
|
$field = \GFFormsModel::get_field($form, $field_id);
|
|
if ($field === null) {
|
|
return '';
|
|
}
|
|
$value = \GFFormsModel::get_field_value($field);
|
|
return is_array($value) ? trim((string) reset($value)) : trim((string) $value);
|
|
}
|
|
|
|
/* ----------------------------------------------------------- duplicate guards */
|
|
|
|
private function is_last_page(array $form): bool {
|
|
// @verify-on-site: GFFormDisplay::get_max_page_number returns 0 for single-page forms.
|
|
if (!class_exists('\GFFormDisplay')) {
|
|
return true;
|
|
}
|
|
$target = (int) rgpost('gform_target_page_number_' . $form['id']);
|
|
$max = (int) \GFFormDisplay::get_max_page_number($form);
|
|
return $max === 0 || $target === 0;
|
|
}
|
|
|
|
private function submission_token(array $form): string {
|
|
$unique = '';
|
|
if (class_exists('\GFFormsModel') && method_exists('\GFFormsModel', 'get_form_unique_id')) {
|
|
$unique = (string) \GFFormsModel::get_form_unique_id((int) $form['id']); // @verify-on-site
|
|
}
|
|
if ($unique === '') {
|
|
// Fallback: stable across re-validation of one submit, unique per real submit.
|
|
$unique = md5(($form['id'] ?? '') . '|' . (string) rgpost('gform_submit') . '|' . (string) wp_date('YmdHi'));
|
|
}
|
|
return 'espinstanda_' . $unique;
|
|
}
|
|
|
|
private function dedupe_key(string $token): string {
|
|
return substr('dk_' . md5($token), 0, 64);
|
|
}
|
|
|
|
private function acquire_lock(string $token): bool {
|
|
$key = 'lock_' . md5($token);
|
|
if (function_exists('wp_cache_add') && wp_cache_add($key, 1, 'espinstanda', self::LOCK_TTL)) {
|
|
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
|
|
return true;
|
|
}
|
|
if (get_transient('espinstanda_' . $key)) {
|
|
return false;
|
|
}
|
|
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
|
|
return true;
|
|
}
|
|
|
|
private function remember(string $token, array $validation_result, bool $passed): array {
|
|
set_transient('espinstanda_res_' . md5($token), [
|
|
'passed' => $passed,
|
|
'message' => $this->pending_message,
|
|
], self::RESULT_TTL);
|
|
return $validation_result;
|
|
}
|
|
|
|
private function replay(string $token, array $validation_result): array {
|
|
$cached = get_transient('espinstanda_res_' . md5($token));
|
|
if (!is_array($cached)) {
|
|
return $validation_result; // unknown - let it pass through unchanged
|
|
}
|
|
if (empty($cached['passed'])) {
|
|
$this->pending_message = $cached['message'] ?? null;
|
|
return $this->mark_form_invalid($validation_result);
|
|
}
|
|
return $validation_result;
|
|
}
|
|
|
|
/* ----------------------------------------------------------- error rendering */
|
|
|
|
private function mark_form_invalid(array $validation_result): array {
|
|
$validation_result['is_valid'] = false;
|
|
return $validation_result;
|
|
}
|
|
|
|
/** Attach mapper error codes to their fields where possible; else a form-level message. */
|
|
private function apply_field_errors(array &$validation_result, array $form, array $feed, array $errors): void {
|
|
$meta = $feed['meta'] ?? [];
|
|
$map = [
|
|
'vendor_type_required' => ['vendor_type', 'Please choose a vendor type.'],
|
|
'performer_type_required' => ['performer_type', 'Please choose a performer type.'],
|
|
'activity_type_required' => ['activity_type', 'Please choose an activity type.'],
|
|
'location_required' => ['location_state', 'Please choose an event location.'],
|
|
'attendance_not_positive_integer' => ['max_attendance', 'Enter a positive number for daily attendance.'],
|
|
'start_required' => ['event_start', 'Please enter a valid start date.'],
|
|
'end_required' => ['event_end', 'Please enter a valid end date.'],
|
|
'end_before_start' => ['event_end', 'The end date must be on or after the start date.'],
|
|
'start_in_past' => ['event_start', 'The start date must be today or later.'],
|
|
'email_required' => ['customer_email', 'Please enter your email address.'],
|
|
'email_invalid' => ['customer_email', 'Please enter a valid email address.'],
|
|
'role_required' => ['role', 'Please choose an event role.'],
|
|
];
|
|
|
|
$validation_result['is_valid'] = false;
|
|
foreach ($validation_result['form']['fields'] as &$field) {
|
|
foreach ($errors as $code) {
|
|
if (!isset($map[$code])) {
|
|
continue;
|
|
}
|
|
[$intent, $message] = $map[$code];
|
|
$mapped_id = (string) ($meta[$intent] ?? '');
|
|
if ($mapped_id !== '' && (string) $field->id === $mapped_id) {
|
|
$field->failed_validation = true;
|
|
$field->validation_message = $message;
|
|
}
|
|
}
|
|
}
|
|
unset($field);
|
|
}
|
|
|
|
private function friendly_failure_message(Api_Result $result): string {
|
|
if ($result->is_ambiguous) {
|
|
return 'We could not confirm your quote. Please try again in a moment - we have saved your details.';
|
|
}
|
|
return 'We could not start your quote just now. Please review your entries and try again.';
|
|
}
|
|
}
|