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 */ 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 $input * @return array */ 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)]; }); } }