} */ public static function check(): array { global $db; $missing = []; foreach (['account_deletion_requests', 'account_deletion_credential_attempts', 'account_deletion_outbox'] as $table) { $tableSql = $db->escape_string($table); $result = $db->query("SHOW TABLES LIKE '$tableSql'"); if ($result === false || $result->num_rows === 0) { $missing[] = 'table:' . $table; } } foreach (['users' => 'deleted_at', 'subusers' => 'deleted_at'] as $table => $column) { $result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'"); if ($result === false || $result->num_rows === 0) { $missing[] = 'column:' . $table . '.' . $column; } } if (!in_array('table:account_deletion_requests', $missing, true)) { $result = $db->query("SHOW COLUMNS FROM account_deletion_requests LIKE 'manual_review_required_at'"); if ($result === false || $result->num_rows === 0) { $missing[] = 'column:account_deletion_requests.manual_review_required_at'; } } return ['ready' => $missing === [], 'missing' => $missing]; } public static function apply(): void { if (PHP_SAPI !== 'cli') { throw new \RuntimeException('Account deletion schema changes are CLI-only.'); } global $db; self::execute("CREATE TABLE IF NOT EXISTS account_deletion_requests ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, request_id CHAR(36) NOT NULL, principal_type VARCHAR(16) NOT NULL, principal_id BIGINT UNSIGNED NOT NULL, customer_number_snapshot INT NULL, active_principal_key VARCHAR(191) NULL, status VARCHAR(32) NOT NULL DEFAULT 'requested', policy_version VARCHAR(32) NOT NULL, retained_data_json LONGTEXT NOT NULL, request_ip VARCHAR(45) NULL, request_user_agent VARCHAR(512) NULL, retry_count INT UNSIGNED NOT NULL DEFAULT 0, failure_code VARCHAR(191) NULL, requested_at DATETIME NOT NULL, processing_at DATETIME NULL, completed_at DATETIME NULL, next_attempt_at DATETIME NULL, manual_review_required_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uniq_account_deletion_request_id (request_id), UNIQUE KEY uniq_account_deletion_active_principal (active_principal_key), INDEX idx_account_deletion_worker (status, next_attempt_at, requested_at), INDEX idx_account_deletion_principal (principal_type, principal_id, requested_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); self::ensureColumn('account_deletion_requests', 'manual_review_required_at', 'DATETIME NULL AFTER `next_attempt_at`'); self::execute("CREATE TABLE IF NOT EXISTS account_deletion_credential_attempts ( throttle_key CHAR(64) NOT NULL, attempt_count INT UNSIGNED NOT NULL DEFAULT 1, window_started_at DATETIME NOT NULL, blocked_until DATETIME NULL, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (throttle_key), INDEX idx_account_deletion_throttle_expiry (updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); self::execute("CREATE TABLE IF NOT EXISTS account_deletion_outbox ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, request_id CHAR(36) NOT NULL, event_type VARCHAR(64) NOT NULL, payload_json LONGTEXT NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'pending', attempts INT UNSIGNED NOT NULL DEFAULT 0, available_at DATETIME NOT NULL, processing_at DATETIME NULL, delivered_at DATETIME NULL, last_error VARCHAR(191) NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uniq_account_deletion_outbox_event (request_id, event_type), INDEX idx_account_deletion_outbox_delivery (status, available_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"); self::ensureColumn('account_deletion_outbox', 'processing_at', 'DATETIME NULL AFTER `available_at`'); self::ensureColumn('users', 'deleted_at', 'DATETIME NULL AFTER `updated_at`'); self::ensureColumn('subusers', 'deleted_at', 'DATETIME NULL AFTER `suspended_at`'); self::ensureIndex('users', 'idx_users_deleted_at', '`deleted_at`'); self::ensureIndex('subusers', 'idx_subusers_deleted_at', '`deleted_at`'); } private static function execute(string $sql): void { global $db; if ($db->query($sql) === false) { throw new \RuntimeException('Account deletion schema operation failed.'); } } private static function ensureColumn(string $table, string $column, string $definition): void { global $db; $result = $db->query("SHOW COLUMNS FROM `$table` LIKE '$column'"); if ($result === false) throw new \RuntimeException('Unable to inspect account deletion schema.'); if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD COLUMN `$column` $definition"); } private static function ensureIndex(string $table, string $index, string $columns): void { global $db; $result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$index'"); if ($result === false) throw new \RuntimeException('Unable to inspect account deletion indexes.'); if ($result->num_rows === 0) self::execute("ALTER TABLE `$table` ADD INDEX `$index` ($columns)"); } }