129 lines
6.8 KiB
PHP
129 lines
6.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Database\QueryException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
/** Technical incident metadata only; never an operational-record ACL. */
|
|
final class OpportunityTechnicalSupport
|
|
{
|
|
private const NOTICE_SOURCE = 'portal-opportunity-support-notices';
|
|
|
|
public function installed(): bool
|
|
{
|
|
return app(TechnicalSupportProfileSchema::class)->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<string,mixed> */
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
}
|
|
}
|