resource_path('js/pages/investment/'.$name.'.tsx'), ['business-opportunity', 'catalogue', 'detail', 'profile', 'zone'])]; sort($files); $hashes = []; foreach ($files as $file) { $hashes[str_replace(base_path().DIRECTORY_SEPARATOR, '', $file)] = hash_file('sha256', $file); } return hash('sha256', json_encode($hashes, JSON_THROW_ON_ERROR)); } /** @return array */ public function preview(): array { $identity = DB::selectOne('select current_database() as database, inet_server_port() as port'); $rows = DB::table('portal_partnerships')->orderBy('id')->get(['id', 'user_id', 'title', 'sector', 'province', 'offer', 'seeking', 'status', 'reviewed_by', 'created_at', 'updated_at'])->all(); $hash = hash('sha256', json_encode(['target' => $identity, 'code' => $this->fingerprint(), 'records' => $rows], JSON_THROW_ON_ERROR)); $proof = $this->read('backup-proof.json'); $qa = $this->read('qa-evidence.json'); $verified = ($proof['restore_verified'] ?? false) && ($proof['target']['database'] ?? null) === $identity->database && (int) ($proof['target']['port'] ?? 0) === (int) $identity->port && is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['sha256'] ?? null); $qaVerified = ($qa['passed'] ?? false) && ($qa['code_hash'] ?? null) === $this->fingerprint() && str_ends_with($qa['database'] ?? '', '_test') && (int) ($qa['port'] ?? 0) === 55441; return ['hash' => $hash, 'installed' => Schema::hasTable('partnership_interests'), 'target' => $identity, 'records' => $rows, 'records_hash' => hash('sha256', json_encode($rows, JSON_THROW_ON_ERROR)), 'backupVerified' => $verified, 'qaVerified' => $qaVerified, 'backupDate' => $proof['verified_at'] ?? null, 'changes' => ['Extensão de portal_partnerships com campos opcionais e versão, sem substituir os registos.', 'Entidades próprias de candidaturas, salas, mensagens, referências documentais, marcos, eventos e notificações locais.', 'Nenhuma cópia de contas, organizações, documentos, setores, territórios ou oportunidades de investimento.', 'Estados e autores antigos preservados; publicação nova continua moderada.']]; } /** @return array */ private function read(string $file): array { $path = config('partnerships.proof_directory', storage_path('app/private/partnership-exchange')).'/'.$file; if (! is_file($path)) { return []; } $contents = file_get_contents($path); abort_unless(is_string($contents), 503, 'Evidência privada indisponível.'); return json_decode($contents, true, 512, JSON_THROW_ON_ERROR); } public function apply(string $hash, User $actor): void { DB::transaction(function () use ($hash, $actor) { DB::statement('LOCK TABLE portal_partnerships IN ACCESS EXCLUSIVE MODE'); $preview = $this->preview(); abort_unless(hash_equals($preview['hash'], $hash), 409, 'Pré-visualização alterada; reveja a instalação.'); if ($preview['installed']) { return; } abort_unless($preview['backupVerified'] && $preview['qaVerified'], 422, 'Backup/restauro e QA desta versão ainda não estão comprovados.'); config(['partnerships.installation_authorized' => true]); try { $migration = require database_path('migrations/'.self::MIGRATION.'.php'); $migration->up(); } finally { config(['partnerships.installation_authorized' => false]); } $rows = DB::table('portal_partnerships')->orderBy('id')->get(['id', 'user_id', 'title', 'sector', 'province', 'offer', 'seeking', 'status', 'reviewed_by', 'created_at', 'updated_at'])->all(); abort_unless(hash_equals($preview['records_hash'], hash('sha256', json_encode($rows, JSON_THROW_ON_ERROR))), 409, 'Os registos antigos não foram preservados.'); DB::table('migrations')->insert(['migration' => self::MIGRATION, 'batch' => (DB::table('migrations')->max('batch') ?? 0) + 1]); DB::table('audit_events')->insert(['source_system' => 'partnership-exchange', 'entity_type' => 'portal_partnerships', 'actor_user_id' => $actor->id, 'action' => 'partnership_module_installed', 'after_data' => json_encode(['preview_hash' => $hash, 'code_hash' => $this->fingerprint(), 'preserved_records' => count($rows), 'migration' => self::MIGRATION])]); }); } }