$payload @return array{token:string,expiresAt:string} */ function sogrA6R2R1IssueToken(array $payload, string $privateRoot): array { $now = time(); $payload['_token'] = [ 'phase' => SOGR_A6R2R1_PHASE, 'version' => SOGR_A6R2R1_VERSION, 'issuedAt' => $now, 'expiresAt' => $now + 900, 'nonce' => bin2hex(random_bytes(20)), ]; $body = sogrA6R2R1B64Encode(sogrA6R2R1Json($payload)); $sig = hash_hmac('sha256', SOGR_A6R2R1_TOKEN_AAD . '.' . $body, sogrA6R2R1SigningKey($privateRoot), true); return ['token' => $body . '.' . sogrA6R2R1B64Encode($sig), 'expiresAt' => gmdate('c', $now + 900)]; } /** @return array */ function sogrA6R2R1VerifyToken(string $token, string $privateRoot): array { $parts = explode('.', trim($token)); if (count($parts) !== 2) throw new ApiException('TOKEN_INVALID', 'Cutover token nema očekivanu strukturu.', 400); [$body, $signature] = $parts; $actual = sogrA6R2R1B64Decode($signature); $expected = hash_hmac('sha256', SOGR_A6R2R1_TOKEN_AAD . '.' . $body, sogrA6R2R1SigningKey($privateRoot), true); if (!hash_equals($expected, $actual)) throw new ApiException('TOKEN_AUTH_FAILED', 'Cutover token nije autentičan.', 400); $payload = json_decode(sogrA6R2R1B64Decode($body), true, 512, JSON_THROW_ON_ERROR); if (!is_array($payload) || !is_array($payload['_token'] ?? null)) throw new ApiException('TOKEN_PAYLOAD_INVALID', 'Cutover token nema očekivane podatke.', 400); $meta = $payload['_token']; $now = time(); if (($meta['phase'] ?? '') !== SOGR_A6R2R1_PHASE || ($meta['version'] ?? '') !== SOGR_A6R2R1_VERSION) { throw new ApiException('TOKEN_VERSION_MISMATCH', 'Cutover token pripada drugoj fazi.', 409); } if ((int) ($meta['issuedAt'] ?? 0) > $now + 60 || (int) ($meta['expiresAt'] ?? 0) < $now) { throw new ApiException('TOKEN_EXPIRED', 'Cutover token je istekao. Ponovite preview.', 409); } unset($payload['_token']); return $payload; } /** @return array|null */ function sogrA6R2R1LatestA6R2Manifest(string $privateRoot): ?array { $files = glob($privateRoot . '/backups/phase7c-a6-r2/*/activation-preflight.json') ?: []; usort($files, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a)); foreach ($files as $path) { try { $data = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); if (is_array($data)) return ['path' => $path, 'sha256' => sogrA6R2R1Hash($path), 'data' => $data]; } catch (Throwable) { } } return null; } /** @return array */ function sogrA6R2R1DatabaseState(PDO $pdo): array { $sequence = (int) $pdo->query("SELECT current_value FROM number_sequences WHERE sequence_name='receipt'")->fetchColumn(); $maxReceipt = (int) $pdo->query('SELECT COALESCE(MAX(receipt_number),0) FROM payments')->fetchColumn(); $maxActive = (int) $pdo->query("SELECT COALESCE(MAX(receipt_number),0) FROM payments WHERE payment_status='ACTIVE'")->fetchColumn(); $pending = (int) $pdo->query("SELECT COUNT(*) FROM sync_queue WHERE queue_status IN ('PENDING','PROCESSING','FAILED','CONFLICT')")->fetchColumn(); $conflicts = (int) $pdo->query("SELECT COUNT(*) FROM sync_conflicts WHERE conflict_status='OPEN'")->fetchColumn(); $stuck = (int) $pdo->query("SELECT COUNT(*) FROM idempotency_requests WHERE request_status='PROCESSING' AND updated_atfetchColumn(); $receipt104 = (int) $pdo->query("SELECT COUNT(*) FROM payments p JOIN receipts r ON r.payment_id=p.id WHERE p.receipt_number=104 AND p.payment_status='ACTIVE' AND r.receipt_status='ACTIVE'")->fetchColumn(); $state = compact('sequence','maxReceipt','maxActive','pending','conflicts','stuck','receipt104'); $checks = [ 'sequenceIs104' => $sequence === 104, 'maxReceiptIs104' => $maxReceipt === 104, 'maxActiveReceiptIs104' => $maxActive === 104, 'noPendingSync' => $pending === 0, 'noOpenConflicts' => $conflicts === 0, 'noStuckIdempotency' => $stuck === 0, 'receipt104Healthy' => $receipt104 === 1, ]; return [ 'valid' => !in_array(false, $checks, true), 'checks' => $checks, 'values' => $state, 'fingerprint' => hash('sha256', sogrA6R2R1Json($state)), ]; } /** @return array */ function sogrA6R2R1FileState(string $documentRoot, string $privateRoot): array { $paths = [ 'productionIndex' => [$documentRoot . '/api/v1/index.php', SOGR_A6R2R1_OLD_INDEX_SHA256, SOGR_A6R2R1_TARGET_INDEX_SHA256], 'productionClient' => [$documentRoot . '/evidencija.html', SOGR_A6R2R1_OLD_CLIENT_SHA256, SOGR_A6R2R1_TARGET_CLIENT_SHA256], 'targetIndex' => [$privateRoot . '/staging/phase7c-a6-r2-r1/index.php', SOGR_A6R2R1_TARGET_INDEX_SHA256, SOGR_A6R2R1_TARGET_INDEX_SHA256], 'targetClient' => [$privateRoot . '/staging/phase7c-a6-r2-r1/evidencija.html', SOGR_A6R2R1_TARGET_CLIENT_SHA256, SOGR_A6R2R1_TARGET_CLIENT_SHA256], 'writeService' => [$privateRoot . '/src/Service/Phase7CWriteService.php', SOGR_A6R2R1_WRITE_SERVICE_SHA256, SOGR_A6R2R1_WRITE_SERVICE_SHA256], 'syncService' => [$privateRoot . '/src/Service/Phase7CSyncService.php', SOGR_A6R2R1_SYNC_SERVICE_SHA256, SOGR_A6R2R1_SYNC_SERVICE_SHA256], 'a6r2Endpoint' => [$documentRoot . '/api/v1/phase7c-a6-r2.php', SOGR_A6R2R1_A6R2_ENDPOINT_SHA256, SOGR_A6R2R1_A6R2_ENDPOINT_SHA256], 'a6r2Service' => [$privateRoot . '/src/Service/Phase7CA6R2CutoverPreflightService.php', SOGR_A6R2R1_A6R2_SERVICE_SHA256, SOGR_A6R2R1_A6R2_SERVICE_SHA256], ]; $checks = []; $files = []; foreach ($paths as $name => [$path, $oldExpected, $targetExpected]) { $hash = sogrA6R2R1Hash($path); $files[$name] = ['path' => $path, 'sha256' => $hash, 'sizeBytes' => is_file($path) ? filesize($path) : null]; if ($name === 'productionIndex' || $name === 'productionClient') { $checks[$name . 'Known'] = $hash !== '' && (hash_equals($oldExpected, $hash) || hash_equals($targetExpected, $hash)); } else { $checks[$name . 'Exact'] = $hash !== '' && hash_equals($targetExpected, $hash); } } $productionMode = ($files['productionIndex']['sha256'] === SOGR_A6R2R1_TARGET_INDEX_SHA256 && $files['productionClient']['sha256'] === SOGR_A6R2R1_TARGET_CLIENT_SHA256) ? 'MYSQL_PRIMARY_ACTIVE' : (($files['productionIndex']['sha256'] === SOGR_A6R2R1_OLD_INDEX_SHA256 && $files['productionClient']['sha256'] === SOGR_A6R2R1_OLD_CLIENT_SHA256) ? 'LEGACY_CLIENT_READ_ONLY_API' : 'UNKNOWN'); return [ 'valid' => !in_array(false, $checks, true) && $productionMode !== 'UNKNOWN', 'checks' => $checks, 'files' => $files, 'productionMode' => $productionMode, 'fingerprint' => hash('sha256', sogrA6R2R1Json($files)), ]; } /** @return array */ function sogrA6R2R1Collect(string $documentRoot, string $privateRoot, PDO $pdo, Phase7CSyncService $syncService): array { $files = sogrA6R2R1FileState($documentRoot, $privateRoot); $database = sogrA6R2R1DatabaseState($pdo); $manifest = sogrA6R2R1LatestA6R2Manifest($privateRoot); $manifestValid = is_array($manifest) && (string) ($manifest['data']['phase'] ?? '') === '7C-A6-R2' && (string) ($manifest['data']['version'] ?? '') === '7C-A6-R2.1'; $sync = $syncService->status(true); $bridge = is_array($sync['bridge'] ?? null) ? $sync['bridge'] : []; $bridgeReady = ($sync['ready'] ?? false) === true && ($bridge['ok'] ?? false) === true && ($bridge['ready'] ?? false) === true && (int) ($bridge['maxReceiptNumber'] ?? -1) === 104; $checks = [ 'filesReady' => ($files['valid'] ?? false) === true, 'databaseReady' => ($database['valid'] ?? false) === true, 'a6r2ManifestReady' => $manifestValid, 'bridgeConfiguredAndHealthy' => $bridgeReady, ]; return [ 'valid' => !in_array(false, $checks, true), 'checks' => $checks, 'files' => $files, 'database' => $database, 'a6r2Manifest' => $manifestValid ? [ 'path' => $manifest['path'], 'sha256' => $manifest['sha256'], 'id' => $manifest['data']['manifestId'] ?? null, 'stateFingerprint' => $manifest['data']['stateFingerprint'] ?? null, ] : null, 'sync' => $sync, 'checkedAtUtc' => gmdate('c'), ]; } function sogrA6R2R1ValidBridgeUrl(string $url): bool { $parts = parse_url($url); $host = strtolower((string) ($parts['host'] ?? '')); return ($parts['scheme'] ?? '') === 'https' && in_array($host, ['script.google.com','script.googleusercontent.com'], true) && str_contains((string) ($parts['path'] ?? ''), '/macros/'); } /** @param array $config */ function sogrA6R2R1WriteConfig(string $privateRoot, array $config): void { $path = $privateRoot . '/phase7c-write-sync.json'; $tmp = $path . '.tmp-' . bin2hex(random_bytes(6)); if (file_put_contents($tmp, sogrA6R2R1Json($config) . "\n", LOCK_EX) === false) { throw new ApiException('SYNC_CONFIG_WRITE_FAILED', 'Privatna sync konfiguracija nije mogla biti zapisana.', 500); } @chmod($tmp, 0600); if (!rename($tmp, $path)) { @unlink($tmp); throw new ApiException('SYNC_CONFIG_RENAME_FAILED', 'Privatna sync konfiguracija nije mogla biti aktivirana.', 500); } @chmod($path, 0600); } function sogrA6R2R1AtomicReplace(string $source, string $destination): void { if (!is_file($source) || !is_readable($source)) throw new RuntimeException('Izvorni fajl nije čitljiv: ' . $source); $dir = dirname($destination); if (!is_dir($dir) || !is_writable($dir)) throw new RuntimeException('Odredišni folder nije upisiv: ' . $dir); $tmp = $destination . '.a6r2r1-' . bin2hex(random_bytes(6)); if (!copy($source, $tmp)) throw new RuntimeException('Kopiranje u privremeni fajl nije uspelo.'); @chmod($tmp, 0644); if (!rename($tmp, $destination)) { @unlink($tmp); throw new RuntimeException('Atomska zamena fajla nije uspela: ' . $destination); } if (function_exists('opcache_invalidate')) @opcache_invalidate($destination, true); } /** @return array|null> */ function sogrA6R2R1ReadSettings(PDO $pdo, array $keys): array { $out = array_fill_keys($keys, null); $placeholders = implode(',', array_fill(0, count($keys), '?')); $stmt = $pdo->prepare("SELECT setting_key,setting_value,value_type,is_secret,description FROM app_settings WHERE setting_key IN ($placeholders)"); $stmt->execute($keys); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) $out[(string) $row['setting_key']] = $row; return $out; } function sogrA6R2R1UpsertSettings(PDO $pdo, array $values): void { $stmt = $pdo->prepare("INSERT INTO app_settings(setting_key,setting_value,value_type,is_secret,description) VALUES(:k,:v,'STRING',0,:d) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value),value_type='STRING',is_secret=0,description=VALUES(description)"); foreach ($values as $key => $value) { $stmt->execute([':k' => $key, ':v' => (string) $value, ':d' => 'Faza 7C završni cutover']); } } function sogrA6R2R1RestoreSettings(PDO $pdo, array $snapshot): void { foreach ($snapshot as $key => $row) { if ($row === null) { $stmt = $pdo->prepare('DELETE FROM app_settings WHERE setting_key=:k'); $stmt->execute([':k' => $key]); } else { $stmt = $pdo->prepare("INSERT INTO app_settings(setting_key,setting_value,value_type,is_secret,description) VALUES(:k,:v,:t,:s,:d) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value),value_type=VALUES(value_type),is_secret=VALUES(is_secret),description=VALUES(description)"); $stmt->execute([':k'=>$key,':v'=>$row['setting_value'],':t'=>$row['value_type'],':s'=>$row['is_secret'],':d'=>$row['description']]); } } } $router->post('login', static fn(Request $request): array => (new AuthService())->login($request)); $router->post('logout', static fn(Request $request): array => (new AuthService())->logout($request)); $router->get('me', static fn(Request $request): array => (new AuthService())->me($request)); $router->post('me', static fn(Request $request): array => (new AuthService())->me($request)); $requireOwner = static function(Request $request) use ($authService): array { $session = $authService->requireUser($request); if (($session['user']['permissions']['canManageBackend'] ?? false) !== true) { throw new ApiException('FORBIDDEN', 'Samo vlasnik sistema može pokrenuti završni cutover.', 403); } return $session; }; $health = static function(Request $request) use ($documentRoot,$privateRoot,$pdo,$syncService): array { $state = sogrA6R2R1Collect($documentRoot,$privateRoot,$pdo,$syncService); return [ 'application' => 'Seoski odbor Gornje Rapče', 'service' => 'SOGR Final Backend Activation API', 'phase' => SOGR_A6R2R1_PHASE, 'version' => SOGR_A6R2R1_VERSION, 'status' => $state['valid'] ? 'ready' : 'blocked', 'productionMode' => $state['files']['productionMode'] ?? 'UNKNOWN', 'ready' => $state['valid'], ]; }; $router->get('healthCheck',$health); $router->post('healthCheck',$health); $configure = static function(Request $request) use ($requireOwner,$privateRoot,$pdo): array { $session = $requireOwner($request); $url = trim((string) $request->input('bridgeUrl','')); $key = trim((string) $request->input('bridgeKey','')); if (!sogrA6R2R1ValidBridgeUrl($url)) throw new ApiException('BRIDGE_URL_INVALID','Unesite važeći HTTPS Apps Script Web app URL.',400); if (strlen($key) < 48) throw new ApiException('BRIDGE_KEY_INVALID','Privatni DB sync ključ mora imati najmanje 48 znakova.',400); $path = $privateRoot . '/phase7c-write-sync.json'; $before = is_file($path) ? (string) file_get_contents($path) : null; try { sogrA6R2R1WriteConfig($privateRoot,[ 'phase'=>SOGR_A6R2R1_PHASE, 'version'=>SOGR_A6R2R1_VERSION, 'bridgeUrl'=>$url, 'bridgeKey'=>$key, 'configuredAtUtc'=>gmdate('c'), 'configuredByUserId'=>(int)($session['user']['id']??0), ]); $probe = (new Phase7CSyncService($pdo,$privateRoot))->bridgeHealth(); if (($probe['ready']??false)!==true || (int)($probe['maxReceiptNumber']??-1)!==104) { throw new RuntimeException('Bridge nije spreman ili maksimalni broj priznanice nije 104.'); } return ['phase'=>SOGR_A6R2R1_PHASE,'version'=>SOGR_A6R2R1_VERSION,'valid'=>true,'configured'=>true,'bridgeHealth'=>$probe,'nextStep'=>'RUN_FINAL_CUTOVER_PREVIEW','checkedAtUtc'=>gmdate('c')]; } catch (Throwable $e) { if ($before === null) @unlink($path); else { file_put_contents($path,$before,LOCK_EX); @chmod($path,0600); } if ($e instanceof ApiException) throw $e; throw new ApiException('BRIDGE_CONFIGURATION_FAILED','Bridge konfiguracija nije potvrđena: '.$e->getMessage(),409,[],$e); } }; $router->post('phase7cA6R2R1ConfigureBridge',$configure); $status = static function(Request $request) use ($requireOwner,$documentRoot,$privateRoot,$pdo,$syncService): array { $requireOwner($request); $state=sogrA6R2R1Collect($documentRoot,$privateRoot,$pdo,$syncService); return ['phase'=>SOGR_A6R2R1_PHASE,'version'=>SOGR_A6R2R1_VERSION,'mode'=>'FINAL_BACKEND_ACTIVATION_GATE','valid'=>$state['valid'],'ready'=>$state['valid'],'writesPerformed'=>false,'publicFilesChanged'=>false,'mysqlBusinessWrites'=>0,'googleSheetsWrites'=>0,'state'=>$state,'nextStep'=>$state['valid']?'RUN_FINAL_CUTOVER_PREVIEW':'STOP_AND_REVIEW_GATE']; }; $router->get('phase7cA6R2R1Status',$status); $router->post('phase7cA6R2R1Status',$status); $preview = static function(Request $request) use ($requireOwner,$documentRoot,$privateRoot,$pdo,$syncService): array { $session=$requireOwner($request); if ($request->input('dryRun',true)!==true) throw new ApiException('DRY_RUN_REQUIRED','Preview zahteva dryRun=true.',400); $state=sogrA6R2R1Collect($documentRoot,$privateRoot,$pdo,$syncService); if (($state['valid']??false)!==true) throw new ApiException('CUTOVER_GATE_NOT_READY','Završna cutover kapija nije spremna.',409,$state); if (($state['files']['productionMode']??'')!=='LEGACY_CLIENT_READ_ONLY_API') throw new ApiException('PRODUCTION_ALREADY_CHANGED','Produkcioni fajlovi više nisu u očekivanom pre-cutover stanju.',409,$state['files']); $plan=[ 'userId'=>(int)($session['user']['id']??0), 'databaseFingerprint'=>(string)$state['database']['fingerprint'], 'fileFingerprint'=>(string)$state['files']['fingerprint'], 'manifestSha256'=>(string)($state['a6r2Manifest']['sha256']??''), 'syncConfigHash'=>sogrA6R2R1Hash($privateRoot.'/phase7c-write-sync.json'), 'confirmationRequired'=>SOGR_A6R2R1_CONFIRMATION, ]; $token=sogrA6R2R1IssueToken($plan,$privateRoot); return ['phase'=>SOGR_A6R2R1_PHASE,'version'=>SOGR_A6R2R1_VERSION,'mode'=>'FINAL_BACKEND_ACTIVATION_PREVIEW','valid'=>true,'writesPerformed'=>false,'publicFilesChanged'=>false,'mysqlBusinessWrites'=>0,'googleSheetsWrites'=>0,'state'=>$state,'confirmationRequired'=>SOGR_A6R2R1_CONFIRMATION,'cutoverToken'=>$token['token'],'cutoverTokenExpiresAt'=>$token['expiresAt'],'nextStep'=>'CLOSE_ALL_OLD_APP_TABS_AND_CONFIRM_FINAL_CUTOVER','checkedAtUtc'=>gmdate('c')]; }; $router->post('phase7cA6R2R1Preview',$preview); $activate = static function(Request $request) use ($requireOwner,$documentRoot,$privateRoot,$pdo,$syncService,$writeService): array { $session=$requireOwner($request); $plan=sogrA6R2R1VerifyToken((string)$request->input('cutoverToken',''),$privateRoot); if (trim((string)$request->input('confirmation',''))!==SOGR_A6R2R1_CONFIRMATION || ($plan['confirmationRequired']??'')!==SOGR_A6R2R1_CONFIRMATION) { throw new ApiException('CONFIRMATION_INVALID','Završna potvrda nije tačna.',400); } if ($request->input('allUsersClosedOldTabs',false)!==true) throw new ApiException('OLD_TABS_NOT_CONFIRMED','Potvrdite da su svi stari prozori aplikacije zatvoreni.',400); if ((int)($session['user']['id']??0)!==(int)($plan['userId']??0)) throw new ApiException('TOKEN_USER_MISMATCH','Cutover token pripada drugom korisniku.',403); $before=sogrA6R2R1Collect($documentRoot,$privateRoot,$pdo,$syncService); if (($before['valid']??false)!==true || ($before['files']['productionMode']??'')!=='LEGACY_CLIENT_READ_ONLY_API') throw new ApiException('CUTOVER_GATE_CHANGED','Stanje se promenilo od preview-a.',409,$before); if (!hash_equals((string)$plan['databaseFingerprint'],(string)$before['database']['fingerprint']) || !hash_equals((string)$plan['fileFingerprint'],(string)$before['files']['fingerprint']) || !hash_equals((string)$plan['manifestSha256'],(string)($before['a6r2Manifest']['sha256']??'')) || !hash_equals((string)$plan['syncConfigHash'],sogrA6R2R1Hash($privateRoot.'/phase7c-write-sync.json'))) { throw new ApiException('CUTOVER_FINGERPRINT_CHANGED','Fajlovi, baza, manifest ili bridge konfiguracija promenjeni su od preview-a.',409); } $backupDir=$privateRoot.'/backups/phase7c-a6-r2-r1/'.gmdate('Ymd-His').'-'.substr($before['database']['fingerprint'],0,12); if (!mkdir($backupDir,0700,true) && !is_dir($backupDir)) throw new ApiException('BACKUP_DIR_FAILED','Cutover backup folder nije mogao biti kreiran.',500); $indexPath=$documentRoot.'/api/v1/index.php'; $clientPath=$documentRoot.'/evidencija.html'; $targetIndex=$privateRoot.'/staging/phase7c-a6-r2-r1/index.php'; $targetClient=$privateRoot.'/staging/phase7c-a6-r2-r1/evidencija.html'; if (!copy($indexPath,$backupDir.'/index.php') || !copy($clientPath,$backupDir.'/evidencija.html')) throw new ApiException('BACKUP_COPY_FAILED','Produkcioni fajlovi nisu mogli biti sačuvani.',500); @chmod($backupDir.'/index.php',0600); @chmod($backupDir.'/evidencija.html',0600); $settingKeys=['phase_7c_status','primary_data_store','phase_7c_activated_at','phase_7c_version']; $settingsBefore=sogrA6R2R1ReadSettings($pdo,$settingKeys); file_put_contents($backupDir.'/settings-before.json',sogrA6R2R1Json($settingsBefore)."\n",LOCK_EX); @chmod($backupDir.'/settings-before.json',0600); $filesChanged=false; $settingsChanged=false; try { sogrA6R2R1AtomicReplace($targetIndex,$indexPath); $filesChanged=true; sogrA6R2R1AtomicReplace($targetClient,$clientPath); if (sogrA6R2R1Hash($indexPath)!==SOGR_A6R2R1_TARGET_INDEX_SHA256 || sogrA6R2R1Hash($clientPath)!==SOGR_A6R2R1_TARGET_CLIENT_SHA256) throw new RuntimeException('Post-cutover hash provera nije prošla.'); $pdo->beginTransaction(); sogrA6R2R1UpsertSettings($pdo,[ 'phase_7c_status'=>'WRITE_ACTIVE', 'primary_data_store'=>'MYSQL', 'phase_7c_activated_at'=>gmdate('c'), 'phase_7c_version'=>SOGR_A6R2R1_VERSION, ]); $pdo->commit(); $settingsChanged=true; $writeStatus=$writeService->status(); $syncStatus=$syncService->status(true); if (($writeStatus['ready']??false)!==true || ($syncStatus['ready']??false)!==true || (($syncStatus['bridge']['ready']??false)!==true)) { throw new RuntimeException('Aktivirani write ili sync servis nije spreman.'); } $after=sogrA6R2R1Collect($documentRoot,$privateRoot,$pdo,$syncService); if (($after['files']['productionMode']??'')!=='MYSQL_PRIMARY_ACTIVE') throw new RuntimeException('Produkcioni režim nije potvrđen kao MYSQL_PRIMARY_ACTIVE.'); $activation=[ 'phase'=>SOGR_A6R2R1_PHASE,'version'=>SOGR_A6R2R1_VERSION,'activatedAtUtc'=>gmdate('c'), 'activatedByUserId'=>(int)($session['user']['id']??0),'backupDir'=>$backupDir, 'before'=>$before,'after'=>$after,'writeStatus'=>$writeStatus,'syncStatus'=>$syncStatus, ]; file_put_contents($backupDir.'/activation-result.json',sogrA6R2R1Json($activation)."\n",LOCK_EX); @chmod($backupDir.'/activation-result.json',0600); return ['phase'=>SOGR_A6R2R1_PHASE,'version'=>SOGR_A6R2R1_VERSION,'mode'=>'MYSQL_PRIMARY_WRITES_ACTIVE','valid'=>true,'activated'=>true,'rollbackExecuted'=>false,'productionFilesChanged'=>true,'mysqlBusinessWrites'=>0,'googleSheetsWrites'=>0,'backupPrivatePath'=>$backupDir,'before'=>$before,'after'=>$after,'writeStatus'=>$writeStatus,'syncStatus'=>$syncStatus,'readyForNextPhase'=>true,'nextStep'=>'READY_FOR_7C_A6_R2_R2_POST_CUTOVER_SMOKE_TEST','importantNote'=>'MySQL je aktiviran kao primarna baza. Google Sheets/Drive se ažuriraju isključivo kroz kontrolisani DB_TO_SHEETS bridge.','checkedAtUtc'=>gmdate('c')]; } catch (Throwable $e) { try { if ($pdo->inTransaction()) $pdo->rollBack(); if ($filesChanged) { sogrA6R2R1AtomicReplace($backupDir.'/index.php',$indexPath); sogrA6R2R1AtomicReplace($backupDir.'/evidencija.html',$clientPath); } if ($settingsChanged) { $pdo->beginTransaction(); sogrA6R2R1RestoreSettings($pdo,$settingsBefore); $pdo->commit(); } } catch (Throwable $rollbackError) { throw new ApiException('CUTOVER_FAILED_ROLLBACK_INCOMPLETE','Cutover nije uspeo, a automatski rollback nije potpuno potvrđen: '.$rollbackError->getMessage(),500,['originalError'=>$e->getMessage()],$e); } throw new ApiException('CUTOVER_FAILED_AND_ROLLED_BACK','Cutover nije uspeo. Produkcioni fajlovi i podešavanja vraćeni su na prethodno stanje: '.$e->getMessage(),500,['rollbackExecuted'=>true,'backupDir'=>$backupDir],$e); } }; $router->post('phase7cA6R2R1Activate',$activate); $router->dispatch($request);