hash_file('sha256', base_path($file)), ['app/Services/OpportunityOriginReview.php', 'app/Models/Opportunity.php', 'app/Http/Controllers/OpportunityOriginReviewController.php', 'resources/js/pages/management/opportunity-origin.tsx']))); } /** @return array */ public function state(int $id): array { $record = Opportunity::findOrFail($id); $events = DB::table('audit_events')->where('source_system', 'opportunity-origin-review')->where('after_data->opportunity_id', $id)->orderByDesc('occurred_at')->orderByDesc('id')->get(); $history = $events->map(function ($event) { $data = json_decode($event->after_data, true); return ['id' => $event->id, 'classification' => $data['classification'], 'reason' => $data['reason'], 'author' => $event->actor_user_id, 'date' => $event->occurred_at]; })->all(); return ['id' => $id, 'reference' => $record->reference, 'title' => $record->title, 'description' => $record->description, 'classification' => $history[0]['classification'] ?? 'undetermined', 'history' => $history, 'snapshot_hash' => hash('sha256', json_encode([$record->getRawOriginal(), $history[0]['id'] ?? null], JSON_THROW_ON_ERROR))]; } /** @return array */ public function preview(int $id, string $classification, string $reason): array { abort_unless(in_array($classification, ['real', 'example', 'undetermined'], true) && trim($reason) !== '', 422, 'Classificação e fundamento são necessários.'); return ['current' => $this->state($id), 'classification' => $classification, 'reason' => trim($reason), 'effect' => $classification === 'example' ? 'Separar do catálogo público, API, pesquisa e mapa; consulta autenticada de demonstração preservada.' : 'Elegível para os filtros públicos editoriais existentes; indeterminado não comprova realidade.', 'relationships_changed' => false, 'ready' => $this->ready()]; } public function ready(): bool { $path = config('opportunity_origin.proof_path', storage_path('app/private/investmoz-v2/opportunity-origin-proof.json')); if (! is_file($path)) { return false; } $proof = json_decode(file_get_contents($path) ?: '{}', true); $identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); return ($proof['restore_verified'] ?? false) && ($proof['reversal_verified'] ?? false) && ($proof['code_hash'] ?? '') === $this->fingerprint() && ($proof['target']['database'] ?? '') === $identity->database && (int) ($proof['target']['port'] ?? 0) === (int) $identity->port && strtotime($proof['verified_at'] ?? '') >= time() - 86400 && is_file($proof['dump_path'] ?? '') && hash_file('sha256', $proof['dump_path']) === ($proof['sha256'] ?? null); } /** @return array */ public function apply(int $id, string $classification, string $reason, string $snapshotHash, int $actor): array { $preview = $this->preview($id, $classification, $reason); abort_unless($preview['ready'], 422, 'Prova atual de backup/restauro e reversão necessária.'); if (app()->environment('testing')) { $identity = DB::selectOne('SELECT current_database() AS database,inet_server_port() AS port'); abort_unless(str_ends_with($identity->database, '_test') && (int) $identity->port === 55441, 409, 'QA exige base isolada.'); } return DB::transaction(function () use ($id, $classification, $reason, $snapshotHash, $actor) { DB::select('SELECT pg_advisory_xact_lock(91026, ?)', [$id]); DB::table('opportunities')->where('id', $id)->lockForUpdate()->first(); $current = $this->state($id); if ($current['classification'] === $classification && ($current['history'][0]['reason'] ?? null) === trim($reason)) { return ['replayed' => true, 'changed' => 0]; } abort_unless(hash_equals($current['snapshot_hash'], $snapshotHash), 409, 'Registo/decisão alterado; reveja a pré-visualização.'); DB::table('audit_events')->insert(['source_system' => 'opportunity-origin-review', 'entity_type' => 'opportunity_origin', 'action' => 'origin_classification_decided', 'actor_user_id' => $actor, 'data_classification' => 'internal', 'occurred_at' => DB::raw('clock_timestamp()'), 'before_data' => json_encode(['classification' => $current['classification']], JSON_THROW_ON_ERROR), 'after_data' => json_encode(['opportunity_id' => $id, 'classification' => $classification, 'reason' => trim($reason), 'reference' => $current['reference'], 'relationships_changed' => false], JSON_THROW_ON_ERROR)]); return ['replayed' => false, 'changed' => 1]; }); } }