fix(instanda): content-based dedupe, resubmit guards, PII retention
- F-01: derive the dedupe key from submission content (form id + mapped values) instead of a per-request token, so identical re-POSTs / retry-after-error collapse to the existing quote. Keeps the per-request lock; adds a content lock race guard and a 30-min recent_delivered_by_dedupe short-circuit. - F-12: refuse resubmit of an already-delivered transaction (AJAX + worker). - F-18: scope the resubmit self-recovery guard to the content dedupe key so a customer's different event is no longer skipped. - F-05: anonymize PII on stale failed/pending rows via the cron; add delete_by_email and wire WP's Erase Personal Data eraser. - F-13: exempt stranded delivered rows (no entry_id) from the retention purge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,5 +28,12 @@ final class Cron_Cleanup {
|
|||||||
if ($deleted > 0) {
|
if ($deleted > 0) {
|
||||||
Logger::debug("Retention purge removed {$deleted} delivered transaction(s) older than {$days} day(s).");
|
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).");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ final class Instanda_AddOn extends \GFFeedAddOn {
|
|||||||
|
|
||||||
add_action('esp_instanda_resubmit', Guard::wrap([Resubmit_Handler::class, 'run']));
|
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
|
// 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.
|
// otherwise break GF's AJAX init and the post-submit INSTANDA redirect.
|
||||||
Boost_Compat::register();
|
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 */
|
/* ------------------------------------------------------------ admin pages */
|
||||||
|
|
||||||
public function register_admin_pages() {
|
public function register_admin_pages() {
|
||||||
@@ -592,6 +616,9 @@ JS;
|
|||||||
if ($tx === null) {
|
if ($tx === null) {
|
||||||
wp_send_json_error(['message' => 'Not found']);
|
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) {
|
if ($tx->environment !== Config::environment()->value) {
|
||||||
wp_send_json_error(['message' => 'Cross-environment resubmit is blocked']);
|
wp_send_json_error(['message' => 'Cross-environment resubmit is blocked']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ final class Submission {
|
|||||||
private const LOCK_TTL = 60; // seconds
|
private const LOCK_TTL = 60; // seconds
|
||||||
private const RESULT_TTL = 120;
|
private const RESULT_TTL = 120;
|
||||||
private const STASH_TTL = 300;
|
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). */
|
/** Wire var key => feed meta key (the mapped GF field id is stored under each). */
|
||||||
private const FIELD_KEYS = [
|
private const FIELD_KEYS = [
|
||||||
@@ -57,20 +58,28 @@ final class Submission {
|
|||||||
|
|
||||||
$token = $this->submission_token($form);
|
$token = $this->submission_token($form);
|
||||||
if (!$this->acquire_lock($token)) {
|
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();
|
$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.
|
// Cross-request duplicate guard: an identical submission already delivered
|
||||||
$existing = $store->find_by_dedupe($dedupe);
|
// recently -> reuse its quote instead of creating a second one at INSTANDA.
|
||||||
if ($existing !== null && $existing->status === Tx_Status::Delivered->value) {
|
$existing = $store->recent_delivered_by_dedupe($dedupe, self::DEDUPE_WINDOW);
|
||||||
|
if ($existing !== null) {
|
||||||
$this->stash_success($dedupe, (string) $existing->quote_ref, $existing->quote_url);
|
$this->stash_success($dedupe, (string) $existing->quote_ref, $existing->quote_url);
|
||||||
return $this->remember($token, $validation_result, true);
|
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();
|
$mapper = new Field_Mapper();
|
||||||
$tz = wp_timezone_string();
|
$tz = wp_timezone_string();
|
||||||
|
|
||||||
@@ -164,10 +173,10 @@ final class Submission {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function read_stash_for_entry(array $form): array {
|
private function read_stash_for_entry(array $form): array {
|
||||||
// Best-effort: the confirmation runs in the same request as a fresh success.
|
// Best-effort: confirmation runs in the same request as a fresh success, so the
|
||||||
$token = $this->submission_token($form);
|
// content dedupe key was recorded in the request-scoped static during validate().
|
||||||
$dedupe = $this->dedupe_key($token);
|
$dedupe = self::$current_dedupe;
|
||||||
return $this->read_stash($dedupe) ?? [];
|
return $dedupe !== null ? ($this->read_stash($dedupe) ?? []) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------- feed + values */
|
/* ----------------------------------------------------------- feed + values */
|
||||||
@@ -228,12 +237,31 @@ final class Submission {
|
|||||||
return 'espinstanda_' . $unique;
|
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 {
|
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)) {
|
if (function_exists('wp_cache_add') && wp_cache_add($key, 1, 'espinstanda', self::LOCK_TTL)) {
|
||||||
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
|
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -251,6 +251,22 @@ final class Transaction_Store {
|
|||||||
return $row ? Transaction::from_row($row) : null;
|
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.
|
* Paged list for the admin browser, scoped to the active environment.
|
||||||
*
|
*
|
||||||
@@ -308,19 +324,43 @@ final class Transaction_Store {
|
|||||||
/* ----------------------------------------------------------------- retention */
|
/* ----------------------------------------------------------------- retention */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Purge ONLY delivered records past the retention window for the given env.
|
* Purge delivered records past the retention window for the given env, EXCEPT
|
||||||
* Pending/failed are never deleted on a timer. Returns the number removed.
|
* 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 {
|
public function purge_delivered_older_than(int $days, Environment $env): int {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = self::table_name();
|
$table = self::table_name();
|
||||||
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
|
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
|
||||||
return (int) $wpdb->query($wpdb->prepare(
|
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
|
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 */
|
/* ----------------------------------------------------------------- internals */
|
||||||
|
|
||||||
private function update(int $id, array $data, ?string $event): void {
|
private function update(int $id, array $data, ?string $event): void {
|
||||||
|
|||||||
@@ -136,15 +136,21 @@ final class Resubmit_Handler {
|
|||||||
if ($tx === null) {
|
if ($tx === null) {
|
||||||
return;
|
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) {
|
if ($tx->environment !== Config::environment()->value) {
|
||||||
$store->append_event($tx_id, 'Resubmit blocked - cross-environment.', 'error');
|
$store->append_event($tx_id, 'Resubmit blocked - cross-environment.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-recovery guard: skip if the customer already has a recent successful quote.
|
// Self-recovery guard: skip only when an IDENTICAL submission already delivered
|
||||||
$recent = $store->recent_success_for_email($tx->customer_email, DAY_IN_SECONDS);
|
// 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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user