Files
Investidor-V2/app/Services/TechnicalIncidentHandling.php
T

139 lines
9.3 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
/** Append-only technical treatment; never invokes an investment transition. */
final class TechnicalIncidentHandling
{
private const SOURCE = 'portal-opportunity-support';
/** Optional read projection must not block the investment editor or other incidents.
* @return array{version:int,status:string,assignee_user_id:int|null,assignee_name:string|null,updated_at:string|null,resolved_at:string|null}|null */
public function summaryFor(string $reference): ?array
{
try {
return $this->stateFor($reference);
} catch (HttpExceptionInterface $error) {
if ($error->getStatusCode() !== 503) {
throw $error;
}
return null;
}
}
private function events(string $reference): Builder
{
return DB::table('audit_events')->where('source_system', self::SOURCE)->where('entity_type', 'opportunity-technical-incident')->where('entity_id', $reference)->where('action', 'incident_handled');
}
/** Caller has already authorized the root incident.
* @return array{version:int,status:string,assignee_user_id:int|null,assignee_name:string|null,updated_at:string|null,resolved_at:string|null} */
public function stateFor(string $reference): array
{
$event = $this->events($reference)->orderByRaw("(after_data->'handling'->'version') DESC NULLS FIRST")->orderByDesc('id')->first(['after_data']);
if ($event === null) {
return ['version' => 0, 'status' => 'open', 'assignee_user_id' => null, 'assignee_name' => null, 'updated_at' => null, 'resolved_at' => null];
}
$value = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['handling'] ?? null;
abort_unless(is_array($value) && is_int($value['version'] ?? null) && $value['version'] > 0 && in_array($value['status'] ?? null, ['open', 'in_progress', 'resolved'], true)
&& array_key_exists('assignee_user_id', $value) && ($value['assignee_user_id'] === null || (is_int($value['assignee_user_id']) && $value['assignee_user_id'] > 0))
&& is_string($value['updated_at'] ?? null) && array_key_exists('resolved_at', $value) && ($value['resolved_at'] === null || is_string($value['resolved_at'])), 503, 'O histórico técnico não pôde ser identificado com segurança.');
return ['version' => $value['version'], 'status' => $value['status'], 'assignee_user_id' => $value['assignee_user_id'],
'assignee_name' => $value['assignee_user_id'] ? User::whereKey($value['assignee_user_id'])->value('name') : null, 'updated_at' => $value['updated_at'], 'resolved_at' => $value['resolved_at']];
}
/** @return array<string,mixed> */
public function detail(User $actor, string $reference): array
{
$root = app(OpportunityTechnicalSupport::class)->root($actor, $reference);
$data = json_decode($root->after_data, true, 512, JSON_THROW_ON_ERROR)['incident'];
$commands = [];
foreach ($this->events($reference)->where('actor_user_id', $actor->id)->selectRaw("after_data->'handling'->>'operation_key' AS key,after_data->'handling'->'version' AS version")->get() as $event) {
$version = is_string($event->version) ? json_decode($event->version, true) : $event->version;
if (is_string($event->key) && Str::isUuid($event->key) && is_int($version) && $version > 0) {
$commands[] = ['key' => $event->key, 'version' => $version];
}
}
$history = $this->events($reference)->leftJoin('users', 'users.id', '=', 'audit_events.actor_user_id')
->orderByRaw("(after_data->'handling'->'version') DESC NULLS FIRST")->orderByDesc('audit_events.id')->paginate(10, ['audit_events.id', 'audit_events.occurred_at', 'audit_events.after_data', 'users.name as actor'], 'history_page')->withQueryString()
->through(function ($event) {
$data = json_decode($event->after_data, true, 512, JSON_THROW_ON_ERROR)['handling'] ?? [];
$note = null;
try {
if (is_string($data['note'] ?? null)) {
$note = Crypt::decryptString($data['note']);
}
} catch (DecryptException) {/* Preserve encrypted original. */
}
return ['id' => $event->id, 'version' => $data['version'] ?? null, 'action' => $data['command'] ?? null, 'status' => $data['status'] ?? null, 'actor' => $event->actor, 'occurred_at' => $event->occurred_at, 'note' => $note, 'note_available' => $note !== null];
})->toArray();
return ['incident' => ['reference' => $reference, 'operation' => $data['operation'], 'observed_version' => $data['observed_version'], 'occurred_at' => $root->occurred_at, 'error_code' => $data['error_code'], 'handling' => $this->stateFor($reference)], 'history' => $history, 'commands' => $commands, 'actorId' => $actor->id];
}
/** @param array<string,mixed> $input
* @return array<string,mixed> */
public function decide(User $actor, string $reference, array $input): array
{
abort_unless(app(OpportunityTechnicalSupport::class)->allows($actor), 403);
$data = Validator::make($input, ['operation_key' => 'required|uuid', 'version' => 'required|integer|min:0', 'action' => 'required|in:claim,release,note,resolve,reopen', 'note' => 'required|string|min:10|max:3000'])->validate();
$key = strtolower($data['operation_key']);
$note = trim($data['note']);
Validator::make(['note' => $note], ['note' => 'required|string|min:10|max:3000'])->validate();
$hash = hash('sha256', json_encode(['version' => (int) $data['version'], 'action' => $data['action'], 'note' => $note], JSON_THROW_ON_ERROR));
return DB::transaction(function () use ($actor, $reference, $data, $key, $note, $hash) {
app(OpportunityTechnicalSupport::class)->root($actor, $reference, true);
$receipt = $this->events($reference)->where('actor_user_id', $actor->id)->whereRaw("after_data->'handling'->>'operation_key' = ?", [$key])->first(['after_data']);
if ($receipt) {
$old = json_decode($receipt->after_data, true, 512, JSON_THROW_ON_ERROR)['handling'];
abort_unless(($old['hash'] ?? null) === $hash, 409, 'Esta tentativa já foi utilizada com outros dados.');
return ['reference' => $reference, 'actor_id' => $actor->id, 'operation_key' => $key, 'result_version' => $old['version'], 'replayed' => true, 'handling' => $this->stateFor($reference)];
}
$before = $this->stateFor($reference);
abort_unless($before['version'] === (int) $data['version'], 409, 'O incidente mudou. Compare o estado actual antes de continuar.');
$after = $before;
$action = $data['action'];
if ($action === 'claim') {
abort_if($before['status'] === 'resolved', 422, 'Reabra o incidente antes de assumir o tratamento.');
abort_if($before['status'] === 'in_progress' && $before['assignee_user_id'] === $actor->id, 422, 'O tratamento já está assumido por si.');
$after['status'] = 'in_progress';
$after['assignee_user_id'] = $actor->id;
} elseif ($action === 'release') {
abort_unless($before['status'] === 'in_progress', 422, 'Só um tratamento em curso pode regressar à fila.');
$after['status'] = 'open';
$after['assignee_user_id'] = null;
} elseif ($action === 'resolve') {
abort_if($before['status'] === 'resolved', 422, 'O incidente já está resolvido.');
$after['status'] = 'resolved';
$after['resolved_at'] = now()->toIso8601String();
} elseif ($action === 'reopen') {
abort_unless($before['status'] === 'resolved', 422, 'Só um incidente resolvido pode ser reaberto.');
$after['status'] = 'open';
$after['assignee_user_id'] = null;
$after['resolved_at'] = null;
}
$after['version']++;
$after['updated_at'] = now()->toIso8601String();
unset($after['assignee_name']);
DB::table('audit_events')->insert(['id' => (string) Str::uuid(), 'correlation_id' => $reference, 'source_system' => self::SOURCE, 'entity_type' => 'opportunity-technical-incident', 'entity_id' => $reference, 'actor_user_id' => $actor->id, 'action' => 'incident_handled', 'data_classification' => 'restricted',
'before_data' => json_encode(['handling' => array_diff_key($before, ['assignee_name' => true])], JSON_THROW_ON_ERROR), 'after_data' => json_encode(['handling' => [...$after, 'operation_key' => $key, 'hash' => $hash, 'command' => $action, 'note' => Crypt::encryptString($note)]], JSON_THROW_ON_ERROR)]);
return ['reference' => $reference, 'actor_id' => $actor->id, 'operation_key' => $key, 'result_version' => $after['version'], 'replayed' => false, 'handling' => $this->stateFor($reference)];
});
}
}