Files
Investidor-V2/app/Services/VirtualDeskInstallation.php
T
nala 6ddafc95ba
tests / ci (push) Canceled after 0s
oportunidades 4
2026-10-10 07:03:59 +02:00

126 lines
10 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use App\Services\Malware\MalwareScanner;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class VirtualDeskInstallation
{
public const MIGRATIONS = ['2026_10_07_190000_create_internal_virtual_desk', '2026_10_08_120000_extend_desk_investor_guidance', '2026_10_09_190000_prepare_investor_path'];
public function fingerprint(): string
{
$files = [...(glob(app_path('Services/Ai/InvestorGuidance*.php')) ?: []), app_path('Services/VirtualDesk.php'), __FILE__,
app_path('Http/Controllers/VirtualDeskController.php'), app_path('Http/Controllers/VirtualDeskOperationsController.php'), app_path('Http/Controllers/InvestorGuidanceController.php'),
app_path('Http/Controllers/VirtualDeskInstallationController.php'), app_path('Http/Middleware/VirtualDeskInstalled.php'), app_path('Services/InstitutionalLinkMonitor.php'),
app_path('Services/Ai/EvidenceRepository.php'), base_path('routes/virtual-desk.php'), config_path('virtual-desk.php'), app_path('Http/Controllers/PortalServicesController.php'), app_path('Http/Middleware/HandleInertiaRequests.php'), resource_path('js/app.tsx'),
resource_path('js/lib/business-financial-plan.ts'), resource_path('js/lib/financial-calculators.ts'), resource_path('js/lib/project-dossier.ts'),
...array_map(fn ($name) => 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'), 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')];
sort($files);
return hash('sha256', json_encode(array_map(fn ($file) => hash_file('sha256', $file), $files), JSON_THROW_ON_ERROR));
}
/** @return array<string,mixed> */
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']])]);
});
}
}