Files
Investidor-V2/app/Services/PortalEditorialNativeNotices.php
T
2026-11-14 11:15:09 +02:00

147 lines
7.9 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/** Adapts existing immutable native revisions to the common inbox; never changes a workflow. */
final class PortalEditorialNativeNotices
{
public function available(): bool
{
return app(PortalEditorialWorkflow::class)->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<int> $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<string,mixed> */
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.'&notice_version='.$version,
'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version];
}
}