This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,316 @@
<?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.';
}
}