147 lines
7.9 KiB
PHP
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.'¬ice_version='.$version,
|
|
'reference' => ($cluster ? 'Competitividade — versão ' : 'Território — versão ').$version];
|
|
}
|
|
}
|