where('source_system', self::SOURCE) ->where('entity_type', 'economic_clusters')->where('entity_id', $cluster); } /** * @return array */ private function decode(?\stdClass $event): array { return $event ? json_decode(Crypt::decryptString(json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['snapshot']), true, 512, JSON_THROW_ON_ERROR) : ['version' => 0, 'draft' => null, 'approved' => null]; } /** * @return array */ public function current(string $cluster): array { return $this->decode($this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->first()); } /** * @return array */ public function management(string $cluster): array { $state = $this->current($cluster); $state['editorial_enabled'] = $this->editorialEnabled(); if (isset($state['workflow'])) { $record = EconomicCluster::findOrFail($cluster); $state['workflow']['fresh'] = hash_equals($state['workflow']['hash'], $this->editionHash($record, $state['draft'])); $state['workflow']['independent_required'] = $this->independentRequired($state['workflow']); } $state['history'] = $this->events($cluster)->orderByRaw("(after_data->>'version')::integer DESC")->limit(50)->get() ->map(fn ($event) => ['version' => json_decode($event->after_data, true)['version'], 'action' => $event->action, 'at' => $event->occurred_at, 'actor' => $event->actor_user_id])->all(); return $state; } /** * @param array $draft * @return array */ public function validateDraft(array $draft, string $cluster): array { $rules = ['version' => 'required|integer|in:1', 'clusterId' => 'required|string|in:'.$cluster, 'sector' => 'present|nullable|string|max:200', 'region' => 'present|nullable|string|max:1000', 'forces' => 'required|array:'.implode(',', self::FORCES)]; foreach (self::FORCES as $force) { $rules["forces.$force"] = 'required|array:pressure,'.implode(',', self::FIELDS); $rules["forces.$force.pressure"] = 'present|nullable|in:low,medium,high'; foreach (self::FIELDS as $field) { $rules["forces.$force.$field"] = 'present|nullable|string|max:5000'; } } $clean = Validator::make($draft, $rules)->validate(); $clean['version'] = 1; $clean['sector'] = trim($clean['sector'] ?? ''); $clean['region'] = trim($clean['region'] ?? ''); foreach (self::FORCES as $force) { foreach (['pressure', ...self::FIELDS] as $field) { $clean['forces'][$force][$field] = trim($clean['forces'][$force][$field] ?? ''); } } return $clean; } /** * @param array|null $draft */ private function assertComplete(?array $draft): void { $missing = ! $draft || ! $draft['sector'] || ! $draft['region']; foreach (self::FORCES as $force) { $row = $draft['forces'][$force] ?? []; $missing = $missing || ! in_array($row['pressure'] ?? '', ['low', 'medium', 'high'], true); foreach (self::FIELDS as $field) { $missing = $missing || trim($row[$field] ?? '') === ''; } } if ($missing) { throw ValidationException::withMessages(['approval' => 'Complete o âmbito e as evidências, fontes, períodos, justificação, limitações e oportunidades das cinco forças antes de aprovar.']); } } /** Reuse the portal's encrypted, append-only audit snapshots; no new schema or mutable publication fields. * @param array $input * @return array */ public function change(EconomicCluster $cluster, int $actor, int $expected, string $action, array $input = []): array { abort_if(! in_array($action, ['saved', 'restored', 'approved', 'withdrawn'], true) && ! $this->editorialEnabled(), 503, 'Circuito editorial dos clusters ainda não activado.'); return DB::transaction(function () use ($cluster, $actor, $expected, $action, $input) { $cluster = EconomicCluster::whereKey($cluster->id)->lockForUpdate()->firstOrFail(); $before = $this->current($cluster->id); abort_if($before['version'] !== $expected, 409, 'A análise foi alterada. Conserve os campos actuais e compare a versão do servidor antes de voltar a guardar.'); $state = $before; if ($this->editorialEnabled() || isset($before['workflow'])) { abort_unless($this->editorialEnabled(), 503, 'Reactive o circuito editorial para continuar esta análise; o histórico e a publicação foram conservados.'); $state = $this->editorialChange($cluster, $actor, $action, $input, $before); } elseif ($action === 'saved') { $state['draft'] = $this->validateDraft($input['draft'], $cluster->id); } elseif ($action === 'restored') { $event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first(); abort_unless($event !== null, 404); $state['draft'] = $this->decode($event)['draft']; } elseif ($action === 'approved') { $this->assertComplete($state['draft']); $state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actor, 'version' => $expected + 1, 'evidence' => $input['reason']]; } elseif ($action === 'withdrawn') { $state['approved'] = null; } else { throw new \LogicException('Unsupported competitiveness action'); } $state['version']++; $eventId = (string) Str::uuid(); DB::table('audit_events')->insert(['id' => $eventId, 'source_system' => self::SOURCE, 'entity_type' => 'economic_clusters', 'entity_id' => $cluster->id, 'external_id' => $cluster->id.':'.$state['version'], 'actor_user_id' => $actor, 'action' => $action, 'data_classification' => 'restricted', 'before_data' => json_encode(['version' => $before['version']], JSON_THROW_ON_ERROR), 'after_data' => json_encode(['version' => $state['version'], 'editorial_state' => $state['workflow']['state'] ?? null, 'restored_from' => $input['revision'] ?? null, 'reason' => Crypt::encryptString($input['reason'] ?? ''), 'snapshot' => Crypt::encryptString(json_encode($state, JSON_THROW_ON_ERROR))], JSON_THROW_ON_ERROR)]); if ($this->editorialEnabled()) { $user = User::findOrFail($actor); app(PortalEditorialNativeNotices::class)->emit($user, $eventId, $state['workflow']['contributors']); } return $this->management($cluster->id); }); } /** * @return array|null */ public function publicData(string $cluster): ?array { $state = $this->current($cluster); $approved = isset($state['workflow']) ? $state['published'] : $state['approved']; // Explicit projection: never return editor identity, approval evidence, history or the unapproved draft. return $approved ? ['draft' => $approved['draft'], 'version' => $approved['version'], 'at' => $approved['at']] : null; } public function editorialEnabled(): bool { return (bool) config('editorial_workflow.cluster_competitiveness'); } /** @param array|null $draft */ private function editionHash(EconomicCluster $cluster, ?array $draft): string { $context = Arr::only($cluster->getAttributes(), ['id', 'code', 'name', 'sector', 'status', 'primary_territory_id', 'territory_version']); return hash('sha256', json_encode(Arr::sortRecursive(['context' => $context, 'draft' => $draft]), JSON_THROW_ON_ERROR)); } /** @param array $input * @param array $state * @return array */ private function editorialChange(EconomicCluster $cluster, int $actorId, string $action, array $input, array $state): array { $actor = User::whereKey($actorId)->firstOrFail(); abort_unless($actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403); abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.'); if (! isset($state['workflow'])) { abort_unless(in_array($action, ['saved', 'restored', 'withdrawn'], true), 409, 'Guarde uma edição privada antes de iniciar o novo circuito.'); // An accepted legacy publication remains public; no retrospective approval is invented. $state['published'] = $state['approved']; $state['approved'] = null; $state['workflow'] = ['state' => 'draft', 'edition' => $state['version'], 'contributors' => [], 'approval' => null, 'hash' => $this->editionHash($cluster, $state['draft']), 'independent_required' => false]; } $workflow = $state['workflow']; if (in_array($action, ['saved', 'restored'], true)) { if ($action === 'saved') { $state['draft'] = $this->validateDraft($input['draft'], $cluster->id); $restoredContributors = []; } else { $event = $this->events($cluster->id)->where('after_data->version', $input['revision'])->first(); abort_unless($event !== null, 404); $restored = $this->decode($event); $state['draft'] = $restored['draft']; $restoredContributors = $restored['workflow']['contributors'] ?? [$event->actor_user_id]; } $state['approved'] = null; $state['workflow'] = ['state' => 'draft', 'edition' => $state['version'] + 1, 'contributors' => array_values(array_unique([...($workflow['state'] === 'published' ? [] : $workflow['contributors']), ...$restoredContributors, $actorId])), 'hash' => $this->editionHash($cluster, $state['draft']), 'approval' => null, 'independent_required' => $this->independentRequired($workflow)]; return $state; } validator($input, ['reason' => 'required|string|min:10|max:3000'])->validate(); $allowed = ['submitted' => ['draft', 'returned'], 'review_started' => ['submitted'], 'returned' => ['in_review'], 'rejected' => ['in_review'], 'approved' => ['in_review'], 'published' => ['approved'], 'archived' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published'], 'withdrawn' => ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved', 'published', 'archived']]; abort_unless(isset($allowed[$action]) && in_array($workflow['state'], $allowed[$action], true), 409, 'Transição incompatível com o estado da edição.'); if (in_array($action, ['submitted', 'approved', 'published'], true)) { abort_unless(hash_equals($workflow['hash'], $this->editionHash($cluster, $state['draft'])), 409, 'O contexto do cluster mudou. Guarde nova edição e volte a conferir.'); } if ($action === 'submitted' || $action === 'approved') { $this->assertComplete($state['draft']); } if ($action === 'approved') { abort_if($this->independentRequired($workflow) && in_array($actorId, $workflow['contributors'], true), 403, 'Aprovação independente necessária para esta edição.'); $workflow['approval'] = ['actor' => $actorId, 'edition' => $workflow['edition'], 'hash' => $workflow['hash'], 'at' => now()->toISOString(), 'reason' => $input['reason']]; $state['approved'] = ['draft' => $state['draft'], 'at' => now()->toISOString(), 'actor' => $actorId, 'version' => $state['version'] + 1, 'evidence' => $input['reason']]; } if ($action === 'published') { $decision = $workflow['approval']; abort_unless($decision && $decision['edition'] === $workflow['edition'] && hash_equals($decision['hash'], $workflow['hash']), 422, 'Aprovação desta edição necessária.'); $reviewer = User::whereKey($decision['actor'])->first(); abort_unless($reviewer && $reviewer->can('manage-portal') && ! app(PortalProfileAccess::class)->has($reviewer, 'auditor'), 403, 'Autoridade da aprovação revogada.'); abort_if($this->independentRequired($workflow) && in_array($reviewer->id, $workflow['contributors'], true), 403, 'Aprovação independente necessária.'); $state['published'] = [...$state['approved'], 'at' => now()->toISOString(), 'version' => $state['version'] + 1]; } if ($action === 'withdrawn' || ($action === 'archived' && $workflow['state'] === 'published')) { $state['published'] = null; } $workflow['state'] = match ($action) { 'submitted' => 'submitted', 'review_started' => 'in_review', 'returned' => 'returned', 'rejected' => 'rejected', 'approved' => 'approved', 'published' => 'published', 'archived' => 'archived', 'withdrawn' => $workflow['state'] === 'published' ? 'archived' : $workflow['state'], }; $state['workflow'] = $workflow; return $state; } /** @param array $workflow */ private function independentRequired(array $workflow): bool { return ($workflow['independent_required'] ?? false) || in_array('cluster_competitiveness', config('editorial_workflow.independent_approval.kinds', []), true); } }