diff --git a/wp-content/plugins/esp-instanda-integration/includes/class-cron-cleanup.php b/wp-content/plugins/esp-instanda-integration/includes/class-cron-cleanup.php index b538d842..c30f781c 100644 --- a/wp-content/plugins/esp-instanda-integration/includes/class-cron-cleanup.php +++ b/wp-content/plugins/esp-instanda-integration/includes/class-cron-cleanup.php @@ -28,5 +28,12 @@ final class Cron_Cleanup { if ($deleted > 0) { Logger::debug("Retention purge removed {$deleted} delivered transaction(s) older than {$days} day(s)."); } + + // Scrub PII from unresolved (failed/pending) rows past the window; they are never + // timer-deleted, so this bounds how long customer PII is retained on them. + $scrubbed = $store->anonymize_stale_unresolved($days); + if ($scrubbed > 0) { + Logger::debug("Retention scrub anonymized PII on {$scrubbed} unresolved transaction(s) older than {$days} day(s)."); + } } } diff --git a/wp-content/plugins/esp-instanda-integration/includes/class-instanda-addon.php b/wp-content/plugins/esp-instanda-integration/includes/class-instanda-addon.php index e2793b16..7eb11b99 100644 --- a/wp-content/plugins/esp-instanda-integration/includes/class-instanda-addon.php +++ b/wp-content/plugins/esp-instanda-integration/includes/class-instanda-addon.php @@ -71,6 +71,9 @@ final class Instanda_AddOn extends \GFFeedAddOn { add_action('esp_instanda_resubmit', Guard::wrap([Resubmit_Handler::class, 'run'])); + // Data-subject erasure: wire customer transactions into WP's Erase Personal Data tool. + add_filter('wp_privacy_personal_data_erasers', Guard::wrap([$this, 'register_privacy_erasers'])); + // Keep Gravity Forms' scripts out of Jetpack Boost's JS concat/defer, which // otherwise break GF's AJAX init and the post-submit INSTANDA redirect. Boost_Compat::register(); @@ -402,6 +405,27 @@ JS; } } + /* ------------------------------------------------------------ privacy */ + + public function register_privacy_erasers($erasers) { + $erasers['esp-instanda'] = [ + 'eraser_friendly_name' => 'INSTANDA quote transactions', + 'callback' => [$this, 'privacy_erase'], + ]; + return $erasers; + } + + /** WP Erase Personal Data callback - deletes all INSTANDA transactions for an email. */ + public function privacy_erase($email, $page = 1) { + $removed = (new Transaction_Store())->delete_by_email((string) $email); + return [ + 'items_removed' => $removed > 0, + 'items_retained' => false, + 'messages' => [], + 'done' => true, + ]; + } + /* ------------------------------------------------------------ admin pages */ public function register_admin_pages() { @@ -592,6 +616,9 @@ JS; if ($tx === null) { wp_send_json_error(['message' => 'Not found']); } + if ($tx->status === Tx_Status::Delivered->value) { + wp_send_json_error(['message' => 'Already delivered - resubmitting would create a duplicate quote']); + } if ($tx->environment !== Config::environment()->value) { wp_send_json_error(['message' => 'Cross-environment resubmit is blocked']); } diff --git a/wp-content/plugins/esp-instanda-integration/includes/class-submission.php b/wp-content/plugins/esp-instanda-integration/includes/class-submission.php index 887dd7cd..6a8caba6 100644 --- a/wp-content/plugins/esp-instanda-integration/includes/class-submission.php +++ b/wp-content/plugins/esp-instanda-integration/includes/class-submission.php @@ -22,6 +22,7 @@ final class Submission { private const LOCK_TTL = 60; // seconds private const RESULT_TTL = 120; private const STASH_TTL = 300; + public const DEDUPE_WINDOW = 1800; // 30 min - collapse identical resubmits to the existing quote /** Wire var key => feed meta key (the mapped GF field id is stored under each). */ private const FIELD_KEYS = [ @@ -57,20 +58,28 @@ final class Submission { $token = $this->submission_token($form); if (!$this->acquire_lock($token)) { - return $this->replay($token, $validation_result); // GATE 4 - duplicate fire: replay cached outcome + return $this->replay($token, $validation_result); // GATE 4 - within-request double-fire } - $dedupe = $this->dedupe_key($token); $store = new Transaction_Store(); + $values = $this->extract_values($form, $feed); + $dedupe = $this->content_dedupe_key($form, $values); // content-addressed, stable across requests - // 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) { + // Cross-request duplicate guard: an identical submission already delivered + // recently -> reuse its quote instead of creating a second one at INSTANDA. + $existing = $store->recent_delivered_by_dedupe($dedupe, self::DEDUPE_WINDOW); + if ($existing !== null) { $this->stash_success($dedupe, (string) $existing->quote_ref, $existing->quote_url); return $this->remember($token, $validation_result, true); } - $values = $this->extract_values($form, $feed); + // Race guard: serialize concurrent identical submits (atomic where a persistent + // object cache is present, e.g. production; degrades to a transient lock locally). + if (!$this->acquire_content_lock($dedupe)) { + $this->pending_message = 'We are finalizing your quote - please wait a moment and try again.'; + return $this->remember($token, $this->mark_form_invalid($validation_result), false); + } + $mapper = new Field_Mapper(); $tz = wp_timezone_string(); @@ -164,10 +173,10 @@ final class Submission { } 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) ?? []; + // Best-effort: confirmation runs in the same request as a fresh success, so the + // content dedupe key was recorded in the request-scoped static during validate(). + $dedupe = self::$current_dedupe; + return $dedupe !== null ? ($this->read_stash($dedupe) ?? []) : []; } /* ----------------------------------------------------------- feed + values */ @@ -228,12 +237,31 @@ final class Submission { return 'espinstanda_' . $unique; } - private function dedupe_key(string $token): string { - return substr('dk_' . md5($token), 0, 64); + /** + * Content-addressed dedupe key: two submissions of the same form with the same + * mapped field values produce the same key, so an identical re-POST or a + * retry-after-error collapses to the existing quote instead of creating a new one. + */ + private function content_dedupe_key(array $form, array $values): string { + $parts = [(string) ($form['id'] ?? '')]; + foreach (self::FIELD_KEYS as $key) { + $v = trim((string) ($values[$key] ?? '')); + $parts[] = $key === 'customer_email' ? strtolower($v) : $v; + } + return 'dk_' . substr(hash('sha256', implode('|', $parts)), 0, 60); } + /** Per-request lock: stops a single submission from double-firing within one request. */ private function acquire_lock(string $token): bool { - $key = 'lock_' . md5($token); + return $this->try_lock('lock_' . md5($token)); + } + + /** Brief atomic lock on the content key: serializes concurrent identical submits. */ + private function acquire_content_lock(string $dedupe): bool { + return $this->try_lock('clock_' . md5($dedupe)); + } + + private function try_lock(string $key): bool { 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; diff --git a/wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php b/wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php index f04bd17f..1cc75477 100644 --- a/wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php +++ b/wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php @@ -251,6 +251,22 @@ final class Transaction_Store { return $row ? Transaction::from_row($row) : null; } + /** + * Most recent delivered transaction for a content-addressed dedupe key within a window. + * Powers the cross-request duplicate guard: an identical submission that already + * delivered recently is reused instead of creating a second quote. + */ + public function recent_delivered_by_dedupe(string $dedupe_key, int $within_seconds): ?Transaction { + global $wpdb; + $table = self::table_name(); + $cutoff = gmdate('Y-m-d H:i:s', time() - $within_seconds); + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$table} WHERE dedupe_key = %s AND status = %s AND delivered_at >= %s ORDER BY id DESC LIMIT 1", + $dedupe_key, Tx_Status::Delivered->value, $cutoff + ), ARRAY_A); + return $row ? Transaction::from_row($row) : null; + } + /** * Paged list for the admin browser, scoped to the active environment. * @@ -308,19 +324,43 @@ final class Transaction_Store { /* ----------------------------------------------------------------- retention */ /** - * Purge ONLY delivered records past the retention window for the given env. - * Pending/failed are never deleted on a timer. Returns the number removed. + * Purge delivered records past the retention window for the given env, EXCEPT + * stranded leads (delivered but never attached to an entry) which must survive so a + * quote whose customer was never notified is not silently lost. Returns rows removed. */ public function purge_delivered_older_than(int $days, Environment $env): int { global $wpdb; $table = self::table_name(); $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS)); return (int) $wpdb->query($wpdb->prepare( - "DELETE FROM {$table} WHERE status = %s AND environment = %s AND delivered_at IS NOT NULL AND delivered_at < %s", + "DELETE FROM {$table} WHERE status = %s AND environment = %s AND entry_id IS NOT NULL AND delivered_at IS NOT NULL AND delivered_at < %s", Tx_Status::Delivered->value, $env->value, $cutoff )); } + /** + * Scrub PII from unresolved (failed/pending) records older than the window while + * keeping the row for audit. Failed/pending rows are never timer-deleted, so without + * this their customer email/payload would be retained indefinitely. Returns rows scrubbed. + */ + public function anonymize_stale_unresolved(int $days): int { + global $wpdb; + $table = self::table_name(); + $cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS)); + return (int) $wpdb->query($wpdb->prepare( + "UPDATE {$table} SET customer_email = '', redacted_payload = '', response_excerpt = NULL + WHERE status IN ('failed', 'pending') AND customer_email <> '' AND created_at < %s", + $cutoff + )); + } + + /** Data-subject erasure: remove every transaction for a customer email. Returns rows deleted. */ + public function delete_by_email(string $email): int { + global $wpdb; + $table = self::table_name(); + return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE customer_email = %s", $email)); + } + /* ----------------------------------------------------------------- internals */ private function update(int $id, array $data, ?string $event): void { diff --git a/wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php b/wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php index cdcc89c1..7af125be 100644 --- a/wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php +++ b/wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php @@ -136,15 +136,21 @@ final class Resubmit_Handler { if ($tx === null) { return; } + if ($tx->status === Tx_Status::Delivered->value) { + $store->append_event($tx_id, 'Resubmit blocked - already delivered (would duplicate the quote).', 'error'); + return; + } if ($tx->environment !== Config::environment()->value) { $store->append_event($tx_id, 'Resubmit blocked - cross-environment.', 'error'); return; } - // Self-recovery guard: skip if the customer already has a recent successful quote. - $recent = $store->recent_success_for_email($tx->customer_email, DAY_IN_SECONDS); + // Self-recovery guard: skip only when an IDENTICAL submission already delivered + // recently. Scoped to the content dedupe key so a customer's different event + // (different key) is never blocked. + $recent = $store->recent_delivered_by_dedupe($tx->dedupe_key, Submission::DEDUPE_WINDOW); if ($recent !== null && $recent->id !== $tx->id) { - $store->append_event($tx_id, 'Resubmit skipped - customer already has a recent successful quote.'); + $store->append_event($tx_id, 'Resubmit skipped - an identical submission already has a recent quote.'); return; }