available() && Schema::hasColumn('portal_editorial_notifications', 'native_event_id'); } public function access(User $actor, string $source): bool { if (! $actor->hasVerifiedEmail() || ! $actor->two_factor_confirmed_at || ! $actor->two_factor_secret || app(PortalProfileAccess::class)->has($actor, 'auditor')) { return false; } if ($source === 'investment-zones') { return app(InvestmentZoneOperationalProfile::class)->editorialEnabled() && app(InvestmentZoneOperationalProfile::class)->reviewAccess($actor); } if (! $actor->can('manage-portal')) { return false; } return match ($source) { 'cluster-competitiveness' => app(ClusterCompetitiveness::class)->editorialEnabled(), 'territorial-editorial-v1' => app(TerritorialRegistry::class)->editorialAvailable(), default => false, }; } /** @return array{clusters:int,territories:int,zones:int} */ public function pending(User $actor): array { $counts = ['clusters' => 0, 'territories' => 0, 'zones' => 0]; if (! $this->available()) { return $counts; } if ($this->access($actor, 'cluster-competitiveness')) { $events = DB::table('audit_events as a')->join('economic_clusters as c', 'c.id', '=', 'a.entity_id') ->where('a.source_system', 'cluster-competitiveness')->whereNull('c.deleted_at') ->whereNotExists(fn ($q) => $q->selectRaw('1')->from('audit_events as newer')->whereColumn('newer.entity_id', 'a.entity_id') ->where('newer.source_system', 'cluster-competitiveness')->whereRaw("(newer.after_data->>'version')::integer > (a.after_data->>'version')::integer")) ->select(['a.id', 'a.after_data'])->orderBy('a.id')->cursor(); foreach ($events as $event) { $payload = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR); $stage = $payload['editorial_state'] ?? null; if ($stage === null) { $state = json_decode(Crypt::decryptString($payload['snapshot']), true, 512, JSON_THROW_ON_ERROR); $stage = $state['workflow']['state'] ?? null; } if ($stage !== null && ! in_array($stage, ['published', 'archived'], true)) { $counts['clusters']++; } } } if ($this->access($actor, 'territorial-editorial-v1')) { $latest = DB::table('audit_events')->where('source_system', 'territorial-editorial-v1') ->selectRaw("DISTINCT ON (entity_id) entity_id,after_data->>'state' AS editorial_state") ->orderBy('entity_id')->orderByRaw("(after_data->>'version_no')::integer DESC"); $counts['territories'] = DB::table('geo_territory_profiles as p')->join('geo_locations as g', 'g.id', '=', 'p.geo_location_id') ->leftJoinSub($latest, 'a', 'a.entity_id', '=', 'g.id')->whereNull('g.deleted_at') ->whereNotIn(DB::raw('COALESCE(a.editorial_state,p.status)'), ['published', 'archived'])->count(); } if ($this->access($actor, 'investment-zones')) { $latest = DB::table('audit_events')->where('source_system', 'investment-zones')->where('entity_type', 'investment_zones')->where('after_data->editorial_workflow', true) ->selectRaw("DISTINCT ON (entity_id) entity_id,after_data->>'editorial_state' AS editorial_state") ->orderBy('entity_id')->orderByRaw("(after_data->>'version_no')::integer DESC"); $counts['zones'] = DB::query()->fromSub($latest, 'a')->join('investment_zones as z', 'z.id', '=', 'a.entity_id')->whereNull('z.deleted_at') ->whereIn('a.editorial_state', ['draft', 'submitted', 'in_review', 'returned', 'rejected', 'approved'])->count(); } return $counts; } /** @param list $participants */ public function emit(User $actor, string $eventId, array $participants): void { if (! $this->available()) { return; } $event = DB::table('audit_events')->where('id', $eventId)->first(); abort_unless($event !== null, 409); if (in_array($event->action, ['saved', 'restored', 'draft_saved', 'profile_prepared', 'profile_reconciled', 'zone_editorial_suspension_rebased'], true)) { return; } $emails = $event->source_system === 'territorial-editorial-v1' ? [...config('territorial.editors', []), ...config('territorial.reviewers', []), ...config('territorial.publishers', [])] : config('portal.managers', []); $recipients = User::query()->where(fn ($q) => $q->whereIn('id', $participants)->orWhereIn(DB::raw('lower(email)'), array_map('strtolower', $emails)))->get(); foreach ($recipients as $recipient) { if ($recipient->id === $actor->id || ! $this->access($recipient, $event->source_system)) { continue; } DB::table('portal_editorial_notifications')->insertOrIgnore(['user_id' => $recipient->id, 'native_event_id' => $eventId, 'edition_id' => null, 'opportunity_id' => null, 'action' => $event->action, 'content_hash' => $this->eventHash($event), 'created_at' => now()]); } } public function visible(User $actor, \stdClass $notice): bool { if (! $this->available() || (int) $notice->user_id !== $actor->id) { return false; } $event = DB::table('audit_events')->where('id', $notice->native_event_id)->first(); if (! $event || ! $this->access($actor, $event->source_system) || ! is_string($notice->content_hash) || ! hash_equals(trim($notice->content_hash), $this->eventHash($event))) { return false; } $table = match ($event->source_system) { 'cluster-competitiveness' => 'economic_clusters', 'investment-zones' => 'investment_zones', default => 'geo_locations' }; return DB::table($table)->where('id', $event->entity_id)->whereNull('deleted_at')->exists(); } private function eventHash(\stdClass $event): string { return hash('sha256', json_encode([$event->id, $event->source_system, $event->entity_type, $event->entity_id, $event->action, $event->after_data], JSON_THROW_ON_ERROR)); } /** @return array */ public function present(\stdClass $notice): array { $event = DB::table('audit_events')->where('id', $notice->native_event_id)->first(); abort_unless($event !== null, 404); $data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR); $cluster = $event->source_system === 'cluster-competitiveness'; $version = $data[$cluster ? 'version' : 'version_no']; if ($event->source_system === 'investment-zones') { return ['id' => $notice->id, 'action' => str_replace('zone_editorial_', '', $notice->action), 'read_at' => $notice->read_at, 'href' => '/management/investment/zones/'.$event->entity_id.'?notice_version='.$version, 'reference' => 'Zona e indicadores — versão '.$version]; } return ['id' => $notice->id, 'action' => $notice->action, 'read_at' => $notice->read_at, 'href' => $cluster ? '/management/clusters/'.$event->entity_id.'/edit?notice_version='.$version : '/management/territorial/editorial?territory='.$event->entity_id.'¬ice_version='.$version, 'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version]; } }