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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user