<?php declare(strict_types=1); if (!defined('DASHBOARD_ROOT')) { http_response_code(403); exit; } class ActionChain { private const ALLOWED_TABLES = [ 'products', 'categories', 'storefront_blocks', 'orders', 'order_items', 'users', 'support_messages', 'faq', 'reviews', 'stores', 'settings', 'nodes', 'node_commands', 'blacklist', 'payment_logs', 'wallets', 'wallet_transactions', 'device_bindings', 'events', 'testimonials', 'social_posts', 'marketing_ad_accounts', 'marketing_campaigns', 'storefront_rows', 'public_ids', 'sec_watchdog_events', 'review_codes', ]; public static function execute( string $table, int $id, array $newData, array $verifyFields, int $adminId, string $action ): array { if (!in_array($table, self::ALLOWED_TABLES, true)) { error_log("[ACTION_CHAIN] Blocked write to unauthorized table: {$table}"); return ['ok' => false, 'error' => 'Unauthorized table.', 'phase' => 'whitelist']; } $encryptFields = [ 'title', 'description', 'short_desc', 'pitch_title', 'pitch_body', 'unit_label', 'meta_json', 'gallery', 'body', 'config_json', 'block_config_enc', 'shipping_name', 'city', 'province', 'postal_code', 'country', 'address', 'email', 'phone', 'product_title_enc', 'product_price_enc', 'store_name', 'tagline', 'campaign_name', ]; foreach ($newData as $col => &$val) { if (in_array($col, $encryptFields, true) && $val !== null && $val !== '' && !Encryption::isEncrypted((string)$val)) { $val = Encryption::encrypt((string)$val, 'product'); } } $realTable = $table; $realPk = 'id'; $before = null; if ($id > 0) { $before = Database::fetchOne("SELECT * FROM `{$realTable}` WHERE `{$realPk}` = :id", [':id' => $id]); if (!$before) { return ['ok' => false, 'error' => "Record {$id} not found in {$realTable}.", 'phase' => 'verify']; } } try { $result = Database::transaction(function () use ($realTable, $realPk, $id, $newData) { if ($id > 0) { $sets = []; $params = [':id' => $id]; foreach ($newData as $col => $val) { $sets[] = "`{$col}` = :{$col}"; $params[":{$col}"] = $val; } $sets[] = "`updated_at` = NOW()"; $sql = "UPDATE `{$realTable}` SET " . implode(', ', $sets) . " WHERE `{$realPk}` = :id"; Database::execute($sql, $params); return $id; } else { $cols = array_keys($newData); $placeholders = array_map(fn($c) => ":{$c}", $cols); $cols[] = 'created_at'; $placeholders[] = 'NOW()'; $params = []; foreach ($newData as $col => $val) { $params[":{$col}"] = $val; } $sql = "INSERT INTO `{$realTable}` (`" . implode('`,`', $cols) . "`) VALUES (" . implode(',', $placeholders) . ")"; return Database::insert($sql, $params); } }); } catch (\Throwable $e) { error_log("[ACTION_CHAIN SAVE FAIL] {$realTable}.{$id}: " . $e->getMessage()); return ['ok' => false, 'error' => 'Save failed.', 'phase' => 'save']; } $savedId = is_int($result) ? $result : $id; $after = Database::fetchOne("SELECT * FROM `{$realTable}` WHERE `{$realPk}` = :id", [':id' => $savedId]); if (!$after) { return ['ok' => false, 'error' => 'Verification read failed.', 'phase' => 'test']; } $mismatches = []; foreach ($verifyFields as $field) { if (!array_key_exists($field, $newData)) continue; $expected = (string) $newData[$field]; $actual = (string) ($after[$field] ?? ''); $compExpected = Encryption::isEncrypted($expected) ? Encryption::decrypt($expected) : $expected; $compActual = Encryption::isEncrypted($actual) ? Encryption::decrypt($actual) : $actual; if ($compExpected !== $compActual) { $mismatches[] = $field; } } if (!empty($mismatches)) { error_log("[ACTION_CHAIN VERIFY FAIL] {$table}.{$savedId}: mismatched fields: " . implode(',', $mismatches)); return [ 'ok' => false, 'error' => 'Post-save verification failed.', 'phase' => 'test', 'mismatched' => $mismatches, ]; } Events::record( Events::CAT_MODIFY, $action, $table, $savedId, ['fields' => array_keys($newData), 'verified' => true], $adminId ); return [ 'ok' => true, 'id' => $savedId, 'before' => $before ? array_intersect_key($before, $newData) : null, 'after' => array_intersect_key($after, $newData), 'phase' => 'complete', ]; } public static function generateRef(string $table, string $column, int $length = 6): string { $max = (int) str_repeat('9', $length); $min = (int) ('1' . str_repeat('0', $length - 1)); do { $ref = (string) random_int($min, $max); $exists = Database::scalar("SELECT COUNT(*) FROM `{$table}` WHERE `{$column}` = :ref", [':ref' => $ref]); } while ($exists > 0); return $ref; } public static function generateSKU(string $prefix = 'CE'): string { do { $sku = $prefix . '-' . strtoupper(bin2hex(random_bytes(4))); $exists = Database::scalar("SELECT COUNT(*) FROM products WHERE sku = :sku", [':sku' => $sku]); } while ($exists > 0); return $sku; } } 