139 lines
9.3 KiB
PHP
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)];
|
|
});
|
|
}
|
|
}
|