Files
Investidor-V2/app/Services/InvestmentZoneOperationalDetailsInstallation.php
T

155 lines
9.2 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use App\Support\CheckedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\HttpKernel\Exception\HttpException;
/** Nominal schema installation. No imports, backfill, publication, permissions or flags. */
class InvestmentZoneOperationalDetailsInstallation
{
public const SOURCE = 'investment-zone-details-installation';
/**
* @return array<array-key, mixed>
*/
public function files(): array
{
return ['app/Models/InvestmentZone.php', 'app/Http/Controllers/InvestmentZoneController.php', 'app/Services/InvestmentZones.php', 'app/Services/InvestmentZoneOperationalProfile.php', 'resources/js/pages/investment/zone.tsx', 'resources/js/pages/investment/zones.tsx', 'routes/investment-zones.php', 'resources/js/components/investment-zone-installation.tsx', 'app/Services/InvestmentZoneOperationalDetails.php', 'app/Services/InvestmentZoneOperationalDetailsInstallation.php', 'resources/js/components/investment-zone-operational-details.tsx', 'database/migrations/2026_10_16_140000_add_investment_zone_operational_details.php'];
}
public function fingerprint(): string
{
$files = array_map(fn ($p) => [$p, hash_file('sha256', base_path($p))], $this->files());
foreach (File::allFiles(public_path('build')) as $file) {
$files[] = ['public/build/'.$file->getRelativePathname(), hash_file('sha256', $file->getPathname())];
}
usort($files, fn ($a, $b) => strcmp($a[0], $b[0]));
return hash('sha256', json_encode($files, JSON_THROW_ON_ERROR));
}
private function authorize(User $actor, bool $write = false): void
{
abort_unless($actor->hasVerifiedEmail() && app(ScopedPortalAdministration::class)->administrative($actor), 403, 'Gestão autorizada necessária.');
if ($write) {
app(PortalProfileInstallation::class)->authorize($actor, true);
}
}
/**
* @param array<array-key, mixed> $target
* @return array<array-key, mixed>
*/
private function evidence(array $target, string $code): array
{
$qa = app()->environment('testing') && (int) $target['port'] === 55441 && str_ends_with($target['database'], '_test')
&& config('database.connections.pgsql.host') === '127.0.0.1' && ! config('database.connections.pgsql.url');
if ($qa) {
return ['ready' => true, 'mode' => 'isolated_QA'];
}
if (app()->environment('testing')) {
return ['ready' => false, 'mode' => 'invalid_QA'];
}
$path = storage_path('app/private/investmoz-v2/park-details/readiness.json');
$p = is_file($path) ? json_decode(CheckedFile::read($path), true) : [];
$date = strtotime($p['verified_at'] ?? '');
$auth = $p['active_authorization'] ?? [];
$ready = $target['database'] === 'investemoz' && (int) $target['port'] === 5432 && ! config('database.connections.pgsql.url')
&& in_array(config('database.connections.pgsql.host'), ['127.0.0.1', 'localhost'], true)
&& ($p['target']['database'] ?? '') === $target['database'] && (int) ($p['target']['port'] ?? 0) === (int) $target['port']
&& ($p['code_hash'] ?? '') === $code && $date !== false && $date <= time() && $date >= time() - 86400
&& ($p['restore_verified'] ?? false) && ($p['files_verified'] ?? false) && ($p['qa_passed'] ?? false) && ($p['compatibility_verified'] ?? false)
&& ($p['restored_boot_verified'] ?? false) && (int) ($p['restore_target']['port'] ?? 0) === 55441 && str_ends_with($p['restore_target']['database'] ?? '', '_test')
&& ($auth['decision_record'] ?? '') !== '' && ($auth['code_hash'] ?? '') === $code && ($auth['migration'] ?? '') === InvestmentZoneOperationalDetails::MIGRATION;
foreach (['dump', 'files'] as $kind) {
$file = $p[$kind.'_path'] ?? '';
$ready = $ready && is_file($file) && hash_equals($p[$kind.'_sha256'] ?? '', CheckedFile::sha256($file));
}
return ['ready' => $ready, 'mode' => 'active_requires_own_authorization_and_fresh_proof', 'proof_hash' => is_file($path) ? hash_file('sha256', $path) : null];
}
/**
* @return array<array-key, mixed>
*/
public function preview(User $actor): array
{
$this->authorize($actor);
$target = (array) DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port');
$code = $this->fingerprint();
$ledgerCount = DB::table('migrations')->where('migration', InvestmentZoneOperationalDetails::MIGRATION)->count();
$ledger = $ledgerCount === 1;
$column = Schema::hasColumn('investment_zones', 'operational_details');
$shape = DB::selectOne("SELECT data_type,is_nullable FROM information_schema.columns WHERE table_schema='public' AND table_name='investment_zones' AND column_name='operational_details'");
$correct = $shape && $shape->data_type === 'jsonb' && $shape->is_nullable === 'YES';
$consistent = ($ledger && $column && $correct) || ($ledgerCount === 0 && ! $column);
$reasons = [];
if (! $consistent) {
$reasons[] = 'Ledger e coluna divergentes; reconciliar sem instalação parcial.';
}
try {
$this->authorize($actor, true);
} catch (HttpException $e) {
$reasons[] = $e->getMessage();
}
$evidence = $this->evidence($target, $code);
if (! $evidence['ready']) {
$reasons[] = 'Decisão específica e backup/restauro/arranque frescos desta revisão necessários.';
}
if (! app(InvestmentZoneOperationalProfile::class)->installed() || DB::table('migrations')->where('migration', InvestmentZoneOperationalProfile::MIGRATION)->count() !== 1) {
$reasons[] = 'Base 130000 ausente ou divergente; instalar/verificar antes de L1.';
}
$state = ['target' => $target, 'migration' => InvestmentZoneOperationalDetails::MIGRATION, 'code_hash' => $code, 'installed' => $ledger && $column,
'consistent' => $consistent, 'ready' => $reasons === [], 'reasons' => $reasons, 'evidence' => $evidence];
return [...$state, 'hash' => hash_hmac('sha256', json_encode([$actor->id, session()->getId(), $state], JSON_THROW_ON_ERROR), config('app.key'))];
}
/**
* @param array<array-key, mixed> $input
* @return array<array-key, mixed>
*/
public function apply(User $actor, array $input): array
{
$this->authorize($actor, true);
abort_if(array_diff(array_keys($input), ['hash', 'idempotency_key', 'confirm', '_token']) !== [], 422, 'Ator, migration ou caminho arbitrário não permitido.');
$data = Validator::make($input, ['hash' => 'required|string|size:64', 'idempotency_key' => 'required|uuid', 'confirm' => 'required|accepted'])->validate();
return DB::transaction(function () use ($actor, $data) {
DB::select('SELECT pg_advisory_xact_lock(161026,140000)');
$requestHash = hash('sha256', json_encode($data, JSON_THROW_ON_ERROR));
$old = DB::table('audit_events')->where('source_system', self::SOURCE)->where('correlation_id', $data['idempotency_key'])->first();
if ($old) {
$receipt = json_decode($old->after_data, true);
abort_unless((int) $old->actor_user_id === $actor->id && hash_equals($receipt['request_hash'], $requestHash), 409, 'Chave já utilizada com outro ator ou conteúdo.');
return [...$receipt['result'], 'replayed' => true];
}
$plan = $this->preview($actor);
abort_unless(hash_equals($plan['hash'], $data['hash']), 409, 'Prévia alterada; consulte novamente.');
abort_unless($plan['ready'], 422, implode(' ', $plan['reasons']));
if (! $plan['installed']) {
config(['investment-zone-details.authenticated_installation' => true]);
try {
abort_unless(Artisan::call('migrate', ['--path' => 'database/migrations/'.InvestmentZoneOperationalDetails::MIGRATION.'.php', '--force' => true]) === 0, 500, 'Falha na instalação nominal.');
} finally {
config(['investment-zone-details.authenticated_installation' => false]);
}
}
abort_unless($this->preview($actor)['installed'], 500, 'Esquema nominal inconsistente.');
$result = ['migration' => $plan['migration'], 'code_hash' => $plan['code_hash'], 'target' => $plan['target'], 'changed' => ! $plan['installed'], 'business_records_changed' => 0, 'replayed' => false, 'receipt' => $data['idempotency_key']];
DB::table('audit_events')->insert(['source_system' => self::SOURCE, 'entity_type' => 'investment-zone-profile', 'entity_id' => null, 'actor_user_id' => $actor->id,
'correlation_id' => $data['idempotency_key'], 'action' => 'schema', 'data_classification' => 'restricted', 'after_data' => json_encode(['request_hash' => $requestHash, 'result' => $result], JSON_THROW_ON_ERROR)]);
return $result;
});
}
}