130 lines
11 KiB
PHP
130 lines
11 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'), 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()];
|
|
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']])]);
|
|
});
|
|
}
|
|
}
|