database_path('migrations/'.$name.'.php'), self::MIGRATIONS), ...(glob(resource_path('js/pages/desk/*.tsx')) ?: []), resource_path('js/components/investor-guidance.tsx'), resource_path('js/components/journey-companion.tsx'), resource_path('js/components/desk-internal-operations.tsx'), resource_path('js/layouts/portal-navigation.tsx'), resource_path('js/layouts/app/app-sidebar-layout.tsx'), resource_path('js/pages/investment/shared.tsx'), resource_path('js/lib/investor-guidance-finance.ts')]; $files = [...$files, app_path('Services/InvestorPath.php'), app_path('Services/InvestorPathDefinition.php'), app_path('Services/SimulationFormDrafts.php'), app_path('Http/Requests/StoreSimulationFormDraftRequest.php'), app_path('Http/Controllers/InvestorPathController.php'), app_path('Http/Controllers/DashboardController.php'), base_path('routes/investor-path.php'), base_path('routes/web.php'), config_path('investor-path.php'), resource_path('js/pages/dashboard.tsx'), resource_path('js/components/simulation-form.tsx'), resource_path('js/lib/investor-path.ts'), database_path('migrations/2026_10_01_000200_create_simulation_execution_core.php'), resource_path('js/components/investment-map-panel.tsx'), resource_path('js/pages/investment/detail.tsx'), resource_path('js/pages/partnerships/catalogue.tsx'), ...(glob(resource_path('js/pages/investor-path/*.tsx')) ?: [])]; $files = [...$files, app_path('Services/VerifiedDocumentBinary.php'), app_path('Services/TerritoryGeometryImport.php'), app_path('Http/Controllers/TerritoryGeometryImportController.php'), config_path('geometry-import.php'), config_path('geometry-correspondences.php'), app_path('Http/Controllers/LandAssistanceController.php'), app_path('Http/Controllers/PartnershipExchangeController.php'), resource_path('js/pages/management/territory-geometry-import.tsx'), base_path('bootstrap/app.php'), app_path('Providers/FortifyServiceProvider.php'), config_path('fortify.php'), resource_path('js/pages/auth/login.tsx'), resource_path('js/pages/auth/two-factor-challenge.tsx')]; $files = [...$files, app_path('Services/OpportunityManagement.php'), app_path('Services/OpportunityRealEstateDraft.php'), config_path('portal.php'), resource_path('data/investmoz-opportunity-modular.schema.json'), resource_path('js/components/app-sidebar.tsx'), resource_path('js/types/opportunity-management.ts'), base_path('vite.config.ts')]; $files = [...$files, resource_path('js/lib/portal-navigation.ts'), resource_path('js/components/portal-grouped-menu.tsx')]; sort($files); $files = [...$files, ...PortalProfileAccess::fingerprintFiles()]; $files = [...$files, app_path('Services/DeskServiceEditor.php'), app_path('Services/DeskServiceAvailability.php'), app_path('Http/Controllers/DeskServiceEditorController.php'), resource_path('js/components/desk-service-history.tsx'), database_path('migrations/2026_11_03_010000_desk_service_effective_dates.php')]; return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR)); } /** @return array */ public function preview(): array { $identity = DB::selectOne('select current_database() as database, inet_server_port() as port'); $directory = config('virtual-desk.proof_directory', storage_path('app/private/virtual-desk')); $read = function ($file) use ($directory) { if (! is_file($directory.'/'.$file)) { return []; } $contents = file_get_contents($directory.'/'.$file); abort_unless(is_string($contents), 503, 'Evidência privada indisponível.'); return json_decode($contents, true, 512, JSON_THROW_ON_ERROR); }; $backup = $read('backup-proof.json'); $qa = $read('guidance-qa-evidence.json'); $compatibility = $read('activation-compatibility.json'); $code = $this->fingerprint(); $backupVerified = ($backup['restore_verified'] ?? false) && ($backup['target']['database'] ?? null) === $identity->database && (int) ($backup['target']['port'] ?? 0) === (int) $identity->port && is_file($backup['dump_path'] ?? '') && hash_file('sha256', $backup['dump_path']) === ($backup['sha256'] ?? null) && ($backup['files_verified'] ?? false) && is_file($backup['files_path'] ?? '') && hash_file('sha256', $backup['files_path']) === ($backup['files_sha256'] ?? null) && strtotime($backup['verified_at'] ?? '') >= time() - 86400; $qaVerified = ($qa['passed'] ?? false) && ($qa['code_hash'] ?? null) === $code && str_ends_with($qa['database'] ?? '', '_test') && (int) ($qa['port'] ?? 0) === 55441; $compatibilityVerified = $backupVerified && ($compatibility['passed'] ?? false) && ($compatibility['code_hash'] ?? null) === $code && ($compatibility['dump_sha256'] ?? null) === ($backup['sha256'] ?? null) && ($compatibility['empty_rollback_verified'] ?? false) && (int) ($compatibility['port'] ?? 0) === 55441; $counts = []; foreach (['desk_cases', 'desk_requests', 'desk_documents', 'desk_messages', 'tasks', 'investor_accounts', 'investment_submissions'] as $table) { $counts[$table] = Schema::hasTable($table) ? DB::table($table)->count() : null; } $pathInstalled = Schema::hasColumn('investor_journeys', 'path_definition_id') && Schema::hasColumn('journey_steps', 'path_detail'); $installed = app(VirtualDesk::class)->installed() && $pathInstalled && DB::table('migrations')->where('migration', '2026_10_09_190000_prepare_investor_path')->exists(); $pathQa = $read('investor-path-qa-evidence.json'); $pathQaVerified = ($pathQa['code_hash'] ?? null) === $code && ($pathQa['api_persistence_passed'] ?? false) && ($pathQa['integrated_ui_ab_passed'] ?? false) && str_ends_with($pathQa['database'] ?? '', '_test') && (int) ($pathQa['port'] ?? 0) === 55441; $privileges = DB::selectOne("select has_schema_privilege(current_user, 'public', 'CREATE') as can_create, pg_has_role(current_user, (select tableowner from pg_tables where schemaname='public' and tablename='tasks'), 'USAGE') as can_alter_tasks"); $databaseReady = (bool) ($privileges->can_create && $privileges->can_alter_tasks); return ['hash' => hash('sha256', json_encode([$identity, $code, $counts, $backupVerified, $qaVerified, $compatibilityVerified, $databaseReady, $pathQaVerified, $pathInstalled, (bool) config('investor-path.enabled')], JSON_THROW_ON_ERROR)), 'code_hash' => $code, 'installed' => $installed, 'baseDeskInstalled' => app(VirtualDesk::class)->installed(), 'pathInstalled' => $pathInstalled, 'pathEnabled' => (bool) config('investor-path.enabled'), 'pathQaVerified' => $pathQaVerified, 'target' => $identity, 'counts' => $counts, 'backupVerified' => $backupVerified, 'qaVerified' => $qaVerified, 'compatibilityVerified' => $compatibilityVerified, 'backupDate' => $backup['verified_at'] ?? null, 'databaseReady' => $databaseReady, 'scanner' => app(MalwareScanner::class)->health(), 'pendingMigrations' => array_values(array_filter(self::MIGRATIONS, fn ($name) => ! DB::table('migrations')->where('migration', $name)->exists())), 'rollback' => 'Reversão vazia ensaiada em cópia isolada. Com dados novos, preservar/exportar pedidos e tarefas; o down recusa eliminar dados. Restauro integral só numa janela autorizada, após reconciliar alterações posteriores ao backup.', 'changes' => ['Completar o Balcão Virtual existente, preservando pedidos anteriores.', 'Acrescentar associações opcionais ao investidor, projecto e percurso existentes.', 'Reutilizar tasks do CRM para acções expressamente assumidas; sem envio externo.', 'Nenhuma publicação de serviço, documento ou candidatura automática.', 'Preparar duas colunas nas entidades existentes e uma versão publicada do percurso de 19 etapas. Não converter dossiers existentes.', 'Não habilitar a navegação por instalação: configuração INVESTOR_PATH_ENABLED permanece uma acção separada.']]; } public function apply(string $hash, User $actor): void { abort_unless($actor->can('manage-portal'), 403); DB::transaction(function () use ($hash, $actor) { DB::select('select pg_advisory_xact_lock(261008, 1)'); $preview = $this->preview(); abort_unless(hash_equals($preview['hash'], $hash), 409, 'Pré-visualização alterada.'); if ($preview['installed']) { return; } abort_unless($preview['backupVerified'] && $preview['qaVerified'] && $preview['compatibilityVerified'] && $preview['databaseReady'], 422, 'Backup/restauro atualizado, compatibilidade/reversão, permissões da base e QA desta versão continuam necessários.'); abort_unless($preview['pathQaVerified'], 422, 'Demonstração A/B na interface e evidência API/persistência desta versão ainda necessárias.'); config(['virtual-desk.installation_authorized' => true]); config(['investor-path.authenticated_installation' => true]); try { foreach (self::MIGRATIONS as $name) { if (! DB::table('migrations')->where('migration', $name)->exists()) { (require database_path('migrations/'.$name.'.php'))->up(); DB::table('migrations')->insert(['migration' => $name, 'batch' => (DB::table('migrations')->max('batch') ?? 0) + 1]); } } } finally { config(['virtual-desk.installation_authorized' => false]); config(['investor-path.authenticated_installation' => false]); } DB::table('audit_events')->insert(['source_system' => 'virtual-desk', 'entity_type' => 'desk_cases', 'actor_user_id' => $actor->id, 'action' => 'desk_installed', 'after_data' => json_encode(['preview_hash' => $hash, 'code_hash' => $preview['code_hash'], 'prior_counts' => $preview['counts']])]); }); } }