inspect()['installed']; } public function allows(User $actor): bool { $access = app(PortalProfileAccess::class); return $this->installed() && $access->available() && ! $access->has($actor, 'auditor') && $access->has($actor, 'technical_support'); } /** @return array */ public function queue(User $actor, string $status = 'all', bool $unread = false): array { abort_unless(in_array($status, ['all', 'open', 'in_progress', 'resolved'], true), 422); $query = $this->query($actor); if ($unread) { $query->whereNotExists($this->seenQuery($actor)); } if ($status !== 'all') { $query->whereRaw("COALESCE((SELECT treatment.after_data->'handling'->>'status' FROM audit_events treatment WHERE treatment.source_system='portal-opportunity-support' AND treatment.entity_type='opportunity-technical-incident' AND treatment.entity_id=event.id AND treatment.action='incident_handled' ORDER BY (treatment.after_data->'handling'->'version') DESC NULLS FIRST,treatment.id DESC LIMIT 1),'open') = ?", [$status]); } return $query->select(['event.id', 'event.occurred_at', 'event.after_data'])->selectSub($this->seenQuery($actor)->select('notice.occurred_at')->limit(1), 'consulted_at') ->orderByDesc('event.occurred_at')->orderByDesc('event.id')->paginate(20)->withQueryString()->through(function ($event) { $data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['incident']; return ['reference' => $event->id, 'occurred_at' => $event->occurred_at, 'operation' => $data['operation'], 'version' => $data['observed_version'], 'error_code' => $data['error_code'], 'handling' => app(TechnicalIncidentHandling::class)->summaryFor($event->id), 'details_url' => '/suporte-tecnico/incidentes/'.$event->id, 'unread' => $event->consulted_at === null]; })->toArray(); } /** Optional navigation projection: failure must not block unrelated portal work. */ public function unreadCount(User $actor): ?int { try { return DB::transaction(fn () => $this->query($actor)->whereNotExists($this->seenQuery($actor))->count()); } catch (QueryException) { return null; } } private function seenQuery(User $actor): Builder { return DB::table('audit_events as notice')->where('notice.source_system', self::NOTICE_SOURCE)->where('notice.entity_type', 'opportunity-technical-incident') ->where('notice.action', 'incident_consulted')->where('notice.actor_user_id', $actor->id)->whereColumn('notice.entity_id', 'event.id'); } /** @return array{unread:bool} */ public function notice(User $actor, string $reference): array { $root = $this->query($actor)->where('event.id', $reference)->select('event.id')->selectSub($this->seenQuery($actor)->select('notice.occurred_at')->limit(1), 'consulted_at')->first() ?? abort(404); return ['unread' => $root->consulted_at === null]; } /** A per-actor, commutative acknowledgement; never a treatment or business transition. * @return array{reference:string,actor_id:int,read:bool,read_at:string} */ public function markConsulted(User $actor, string $reference): array { return DB::transaction(function () use ($actor, $reference) { $this->root($actor, $reference, true); $query = DB::table('audit_events')->where('source_system', self::NOTICE_SOURCE)->where('entity_type', 'opportunity-technical-incident')->where('entity_id', $reference) ->where('action', 'incident_consulted')->where('actor_user_id', $actor->id); $seen = $query->first(['occurred_at']); if ($seen === null) { $id = (string) Str::uuid(); DB::table('audit_events')->insert(['id' => $id, 'correlation_id' => $reference, 'source_system' => self::NOTICE_SOURCE, 'entity_type' => 'opportunity-technical-incident', 'entity_id' => $reference, 'actor_user_id' => $actor->id, 'action' => 'incident_consulted', 'data_classification' => 'restricted', 'after_data' => json_encode(['consulted' => true], JSON_THROW_ON_ERROR)]); $seen = $query->first(['occurred_at']); } $readAt = $seen?->occurred_at; abort_unless(is_string($readAt), 503, 'A confirmação da consulta não ficou disponível; pode repetir esta acção.'); return ['reference' => $reference, 'actor_id' => $actor->id, 'read' => true, 'read_at' => $readAt]; }); } public function root(User $actor, string $reference, bool $lock = false): \stdClass { $query = $this->query($actor)->where('event.id', $reference)->select(['event.id', 'event.occurred_at', 'event.after_data']); if ($lock) { $query->lock('FOR UPDATE OF event'); } return $query->first() ?? abort(404); } private function query(User $actor): Builder { abort_unless($this->allows($actor), 403); $grants = app(PortalProfileAccess::class)->grants($actor, 'technical_support'); return DB::table('audit_events as event')->join('opportunities as opportunity', DB::raw('opportunity.id::text'), '=', DB::raw("event.after_data->'incident'->>'opportunity_id'")) ->where('event.source_system', 'portal-opportunity-incident')->where('event.entity_type', 'opportunity-technical-incident')->where('event.action', 'workflow_failed')->whereNull('opportunity.deleted_at') ->whereRaw("opportunity.opportunity_category = event.after_data->'incident'->>'category'") ->whereRaw("COALESCE(opportunity.organization_id::text,'') = COALESCE(event.after_data->'incident'->>'organization_id','')") ->where(function (Builder $scopes) use ($grants) { $scopes->whereRaw('FALSE'); foreach ($grants as $grant) { $scopes->orWhere(function (Builder $scope) use ($grant) { $scope->whereIn('opportunity.opportunity_category', json_decode($grant->opportunity_types, true, 512, JSON_THROW_ON_ERROR)); if ($grant->organization_id !== null) { $scope->where('opportunity.organization_id', $grant->organization_id); } }); } }); } }