- 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>
389 lines
16 KiB
PHP
389 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* Transaction_Store - the write-ahead operational store. A record is saved with
|
|
* status 'pending' BEFORE the INSTANDA HTTP call, so a fatal or timeout can never
|
|
* lose a submission. Powers the resubmit queue and retention.
|
|
*
|
|
* Retention is delivered-only: the cron purge removes confirmed-delivered records
|
|
* past their window and NEVER deletes pending or failed records (they wait for an
|
|
* admin to resolve them).
|
|
*
|
|
* @package ESP\Instanda
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace ESP\Instanda;
|
|
|
|
/** Lifecycle status of a transaction. */
|
|
enum Tx_Status: string {
|
|
case Pending = 'pending';
|
|
case Delivered = 'delivered';
|
|
case Failed = 'failed';
|
|
}
|
|
|
|
/** Hydrated transaction row. */
|
|
final class Transaction {
|
|
public int $id = 0;
|
|
public ?int $entry_id = null;
|
|
public ?int $form_id = null;
|
|
public string $status = 'pending';
|
|
public string $environment = 'test';
|
|
public string $dedupe_key = '';
|
|
public bool $possible_duplicate = false;
|
|
public string $customer_email = '';
|
|
public string $role_label = '';
|
|
public ?string $quote_ref = null;
|
|
public ?string $quote_url = null;
|
|
public string $redacted_payload = '';
|
|
public ?int $http_status = null;
|
|
public ?string $response_excerpt = null;
|
|
public int $retry_count = 0;
|
|
public ?string $error_detail = null;
|
|
/** @var list<array{ts:string,level:string,message:string}> */
|
|
public array $event_log = [];
|
|
public string $created_at = '';
|
|
public ?string $delivered_at = null;
|
|
public ?string $expiry = null;
|
|
|
|
/** @param array<string,mixed> $row */
|
|
public static function from_row(array $row): self {
|
|
$t = new self();
|
|
$t->id = (int) ($row['id'] ?? 0);
|
|
$t->entry_id = isset($row['entry_id']) ? (int) $row['entry_id'] : null;
|
|
$t->form_id = isset($row['form_id']) ? (int) $row['form_id'] : null;
|
|
$t->status = (string) ($row['status'] ?? 'pending');
|
|
$t->environment = (string) ($row['environment'] ?? 'test');
|
|
$t->dedupe_key = (string) ($row['dedupe_key'] ?? '');
|
|
$t->possible_duplicate = (bool) ($row['possible_duplicate'] ?? false);
|
|
$t->customer_email = (string) ($row['customer_email'] ?? '');
|
|
$t->role_label = (string) ($row['role_label'] ?? '');
|
|
$t->quote_ref = $row['quote_ref'] !== null ? (string) $row['quote_ref'] : null;
|
|
$t->quote_url = $row['quote_url'] !== null ? (string) $row['quote_url'] : null;
|
|
$t->redacted_payload = (string) ($row['redacted_payload'] ?? '');
|
|
$t->http_status = isset($row['http_status']) && $row['http_status'] !== null ? (int) $row['http_status'] : null;
|
|
$t->response_excerpt = $row['response_excerpt'] !== null ? (string) $row['response_excerpt'] : null;
|
|
$t->retry_count = (int) ($row['retry_count'] ?? 0);
|
|
$t->error_detail = $row['error_detail'] !== null ? (string) $row['error_detail'] : null;
|
|
$t->event_log = $row['event_log'] ? (array) json_decode((string) $row['event_log'], true) : [];
|
|
$t->created_at = (string) ($row['created_at'] ?? '');
|
|
$t->delivered_at = $row['delivered_at'] !== null ? (string) $row['delivered_at'] : null;
|
|
$t->expiry = $row['expiry'] !== null ? (string) $row['expiry'] : null;
|
|
return $t;
|
|
}
|
|
}
|
|
|
|
final class Transaction_Store {
|
|
|
|
public const DB_VERSION = '1.0.0';
|
|
public const DB_VERSION_OPTION = 'espinstanda_db_version';
|
|
private const TABLE = 'esp_instanda_transactions';
|
|
|
|
public static function table_name(): string {
|
|
global $wpdb;
|
|
return $wpdb->prefix . self::TABLE;
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- schema */
|
|
|
|
public static function install_table(): void {
|
|
global $wpdb;
|
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
|
|
|
$table = self::table_name();
|
|
$charset = $wpdb->get_charset_collate();
|
|
|
|
$sql = "CREATE TABLE {$table} (
|
|
id bigint(20) unsigned NOT NULL auto_increment,
|
|
entry_id bigint(20) unsigned DEFAULT NULL,
|
|
form_id bigint(20) unsigned DEFAULT NULL,
|
|
status varchar(20) NOT NULL DEFAULT 'pending',
|
|
environment varchar(10) NOT NULL DEFAULT 'test',
|
|
dedupe_key varchar(64) NOT NULL DEFAULT '',
|
|
possible_duplicate tinyint(1) NOT NULL DEFAULT 0,
|
|
customer_email varchar(191) NOT NULL DEFAULT '',
|
|
role_label varchar(191) NOT NULL DEFAULT '',
|
|
quote_ref varchar(100) DEFAULT NULL,
|
|
quote_url text DEFAULT NULL,
|
|
redacted_payload longtext NOT NULL,
|
|
http_status smallint(5) unsigned DEFAULT NULL,
|
|
response_excerpt text DEFAULT NULL,
|
|
retry_count tinyint(3) unsigned NOT NULL DEFAULT 0,
|
|
error_detail text DEFAULT NULL,
|
|
event_log longtext DEFAULT NULL,
|
|
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
|
|
delivered_at datetime DEFAULT NULL,
|
|
expiry datetime DEFAULT NULL,
|
|
PRIMARY KEY (id),
|
|
KEY status (status),
|
|
KEY environment (environment),
|
|
KEY created_at (created_at),
|
|
KEY expiry (expiry),
|
|
KEY entry_id (entry_id),
|
|
KEY dedupe_key (dedupe_key),
|
|
KEY status_env (status,environment)
|
|
) {$charset};";
|
|
|
|
dbDelta($sql);
|
|
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
|
|
}
|
|
|
|
public static function needs_migration(): bool {
|
|
return version_compare((string) get_option(self::DB_VERSION_OPTION, '0'), self::DB_VERSION, '<');
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- writes */
|
|
|
|
/** Write-ahead insert. Returns the new row id (0 on failure). */
|
|
public function create_pending(Transaction $t): int {
|
|
global $wpdb;
|
|
$now = current_time('mysql');
|
|
$t->event_log[] = self::event('Write-ahead record created (status: pending)');
|
|
|
|
$ok = $wpdb->insert(self::table_name(), [
|
|
'entry_id' => $t->entry_id,
|
|
'form_id' => $t->form_id,
|
|
'status' => Tx_Status::Pending->value,
|
|
'environment' => $t->environment,
|
|
'dedupe_key' => $t->dedupe_key,
|
|
'possible_duplicate' => 0,
|
|
'customer_email' => $t->customer_email,
|
|
'role_label' => $t->role_label,
|
|
'redacted_payload' => $t->redacted_payload,
|
|
'retry_count' => 0,
|
|
'event_log' => wp_json_encode($t->event_log),
|
|
'created_at' => $now,
|
|
], ['%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%d', '%s', '%s']);
|
|
|
|
return $ok ? (int) $wpdb->insert_id : 0;
|
|
}
|
|
|
|
public function mark_success(int $id, string $quote_ref, ?string $quote_url, ?int $http_status): void {
|
|
$delivered = current_time('mysql');
|
|
$this->update($id, [
|
|
'status' => Tx_Status::Delivered->value,
|
|
'quote_ref' => $quote_ref,
|
|
'quote_url' => $quote_url,
|
|
'http_status' => $http_status,
|
|
'delivered_at' => $delivered,
|
|
'expiry' => $this->compute_expiry($delivered),
|
|
], 'Delivered - quoteRef ' . $quote_ref);
|
|
}
|
|
|
|
public function mark_failed(int $id, ?int $http_status, ?string $response_excerpt, string $error_detail): void {
|
|
$this->update($id, [
|
|
'status' => Tx_Status::Failed->value,
|
|
'http_status' => $http_status,
|
|
'response_excerpt' => $response_excerpt,
|
|
'error_detail' => $error_detail,
|
|
], 'Failed - ' . $error_detail);
|
|
}
|
|
|
|
public function mark_ambiguous(int $id, ?int $http_status, string $error_detail): void {
|
|
$this->update($id, [
|
|
'status' => Tx_Status::Failed->value,
|
|
'possible_duplicate' => 1,
|
|
'http_status' => $http_status,
|
|
'error_detail' => $error_detail,
|
|
], 'Ambiguous timeout - flagged possible duplicate');
|
|
}
|
|
|
|
public function increment_retry(int $id): void {
|
|
global $wpdb;
|
|
$table = self::table_name();
|
|
$wpdb->query($wpdb->prepare("UPDATE {$table} SET retry_count = retry_count + 1 WHERE id = %d", $id));
|
|
}
|
|
|
|
public function attach_entry(int $id, int $entry_id): void {
|
|
$this->update($id, ['entry_id' => $entry_id], null);
|
|
}
|
|
|
|
public function append_event(int $id, string $message, string $level = 'info'): void {
|
|
$t = $this->find($id);
|
|
if ($t === null) {
|
|
return;
|
|
}
|
|
$t->event_log[] = self::event($message, $level);
|
|
$this->update($id, ['event_log' => wp_json_encode($t->event_log)], null);
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- reads */
|
|
|
|
public function find(int $id): ?Transaction {
|
|
global $wpdb;
|
|
$table = self::table_name();
|
|
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A);
|
|
return $row ? Transaction::from_row($row) : null;
|
|
}
|
|
|
|
public function find_by_dedupe(string $dedupe_key): ?Transaction {
|
|
global $wpdb;
|
|
$table = self::table_name();
|
|
$row = $wpdb->get_row(
|
|
$wpdb->prepare("SELECT * FROM {$table} WHERE dedupe_key = %s ORDER BY id DESC LIMIT 1", $dedupe_key),
|
|
ARRAY_A
|
|
);
|
|
return $row ? Transaction::from_row($row) : null;
|
|
}
|
|
|
|
public function find_by_entry(int $entry_id): ?Transaction {
|
|
global $wpdb;
|
|
$table = self::table_name();
|
|
$row = $wpdb->get_row(
|
|
$wpdb->prepare("SELECT * FROM {$table} WHERE entry_id = %d ORDER BY id DESC LIMIT 1", $entry_id),
|
|
ARRAY_A
|
|
);
|
|
return $row ? Transaction::from_row($row) : null;
|
|
}
|
|
|
|
/**
|
|
* Most recent delivered transaction for a customer email within a window.
|
|
* Self-recovery guard: do not resubmit/email if the customer already succeeded.
|
|
*/
|
|
public function recent_success_for_email(string $email, 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 customer_email = %s AND status = %s AND delivered_at >= %s ORDER BY id DESC LIMIT 1",
|
|
$email, Tx_Status::Delivered->value, $cutoff
|
|
), ARRAY_A);
|
|
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.
|
|
*
|
|
* @param array{status?:string} $filters
|
|
* @return list<Transaction>
|
|
*/
|
|
public function query(array $filters, int $page, int $per_page): array {
|
|
global $wpdb;
|
|
$table = self::table_name();
|
|
$where = ['environment = %s'];
|
|
$args = [Config::environment()->value];
|
|
|
|
if (!empty($filters['status']) && in_array($filters['status'], ['delivered', 'failed', 'pending'], true)) {
|
|
$where[] = 'status = %s';
|
|
$args[] = $filters['status'];
|
|
}
|
|
|
|
$offset = max(0, ($page - 1) * $per_page);
|
|
$sql = "SELECT * FROM {$table} WHERE " . implode(' AND ', $where)
|
|
. ' ORDER BY id DESC LIMIT %d OFFSET %d';
|
|
$args[] = $per_page;
|
|
$args[] = $offset;
|
|
|
|
$rows = $wpdb->get_results($wpdb->prepare($sql, $args), ARRAY_A) ?: [];
|
|
return array_map([Transaction::class, 'from_row'], $rows);
|
|
}
|
|
|
|
/** Status counts for the active environment (powers the list-table views). */
|
|
public function counts(): array {
|
|
global $wpdb;
|
|
$table = self::table_name();
|
|
$env = Config::environment()->value;
|
|
$rows = $wpdb->get_results($wpdb->prepare(
|
|
"SELECT status, COUNT(*) AS n FROM {$table} WHERE environment = %s GROUP BY status", $env
|
|
), ARRAY_A) ?: [];
|
|
|
|
$counts = ['all' => 0, 'delivered' => 0, 'failed' => 0, 'pending' => 0];
|
|
foreach ($rows as $row) {
|
|
$status = (string) $row['status'];
|
|
$n = (int) $row['n'];
|
|
$counts['all'] += $n;
|
|
if (isset($counts[$status])) {
|
|
$counts[$status] += $n;
|
|
}
|
|
}
|
|
return $counts;
|
|
}
|
|
|
|
/** Unresolved (failed + pending) count in the active env - powers the failure badge. */
|
|
public function unresolved_count(): int {
|
|
$c = $this->counts();
|
|
return $c['failed'] + $c['pending'];
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- retention */
|
|
|
|
/**
|
|
* 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 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 {
|
|
global $wpdb;
|
|
if ($event !== null) {
|
|
$t = $this->find($id);
|
|
if ($t !== null) {
|
|
$t->event_log[] = self::event($event);
|
|
$data['event_log'] = wp_json_encode($t->event_log);
|
|
}
|
|
}
|
|
$wpdb->update(self::table_name(), $data, ['id' => $id]);
|
|
}
|
|
|
|
private function compute_expiry(string $delivered_at): string {
|
|
$days = (int) get_option('espinstanda_retention_days', 7);
|
|
$ts = strtotime($delivered_at) ?: time();
|
|
return gmdate('Y-m-d H:i:s', $ts + ($days * DAY_IN_SECONDS));
|
|
}
|
|
|
|
/** @return array{ts:string,level:string,message:string} */
|
|
private static function event(string $message, string $level = 'info'): array {
|
|
return ['ts' => current_time('mysql'), 'level' => $level, 'message' => Logger::redact($message)];
|
|
}
|
|
}
|