*/ public function guide(string $slug): array { $guide = config('investment-guides.'.$slug); abort_unless(is_array($guide), 404); return [...$guide, 'sha256' => hash('sha256', json_encode($guide, JSON_THROW_ON_ERROR))]; } /** Only coded choices pass through public/authentication URLs. * @param array $input * @return array */ public function selection(array $input): array { $v = Validator::make($input, ['slug' => ['required', 'string', Rule::in(array_keys(config('investment-guides')))], 'sha256' => 'required|string|size:64'])->validate(); $guide = $this->guide($v['slug']); abort_unless(hash_equals($guide['sha256'], $v['sha256']), 409, 'O guia mudou desde a consulta. Abra a edição actual antes de preparar uma nova referência.'); return ['slug' => $v['slug'], 'sha256' => $v['sha256'], 'instruments' => $this->choices($input, $guide)]; } /** @param array $input * @param array $guide * @return list */ private function choices(array $input, array $guide): array { $v = Validator::make($input, ['instruments' => ['sometimes', 'array', 'list', 'max:20'], 'instruments.*' => ['string', Rule::in(array_keys($guide['instruments']))]])->validate(); $values = array_values(array_unique($v['instruments'] ?? [])); sort($values); return $values; } /** @param array $input * @return array */ public function publicView(string $slug, array $input): array { $guide = $this->guide($slug); return ['guide' => $guide, 'instruments' => $this->choices($input, $guide), 'note' => '', 'case' => null, 'saved' => null, 'canWrite' => false, 'historical' => false, 'olderEdition' => false, 'currentAvailable' => true, 'workingCopy' => false, 'history' => [], 'historyNext' => null, 'contextChanged' => false, 'unavailableInstruments' => []]; } /** @param array $input * @return array */ public function view(User $actor, string $id, string $slug, array $input): array { $case = $this->desk->case($actor, $id); $current = is_array(config('investment-guides.'.$slug)) ? $this->guide($slug) : null; $v = Validator::make($input, ['revision' => 'nullable|uuid', 'history_before' => 'nullable|integer|min:1', 'current' => 'nullable|boolean', 'selection' => 'nullable|boolean'])->validate(); $saved = $this->desk->decode($case->payload)['investment_guides'][$slug] ?? null; if (! empty($v['revision'])) { abort_if(isset($input['instruments']) || ! empty($v['current']) || ! empty($v['selection']), 422, 'A revisão histórica conserva a referência original.'); $row = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->where('id', $v['revision'])->first(); abort_unless($row !== null, 404); $saved = $this->desk->decode($row->payload)['snapshot']; abort_unless($saved['guide']['slug'] === $slug, 404); } $working = array_key_exists('instruments', $input) || ! empty($v['current']) || ! empty($v['selection']); abort_unless($current !== null || $saved !== null, 404); abort_if($working && $current === null, 409, 'O guia foi retirado da consulta actual; as referências guardadas continuam disponíveis.'); if ($working && isset($input['sha256'])) { $this->selection([...$input, 'slug' => $slug]); } $guide = $working || ! $saved ? $current : $saved['guide']; $selected = array_key_exists('instruments', $input) || ! empty($v['selection']) ? $this->choices($input, $guide) : ($working ? array_values(array_intersect($saved['instruments'] ?? [], array_keys($guide['instruments']))) : ($saved['instruments'] ?? [])); $events = DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT) ->when(! empty($v['history_before']), fn ($q) => $q->where('event_order', '<', $v['history_before'])) ->orderByDesc('event_order')->cursor(); $history = []; foreach ($events as $event) { $snapshot = $this->desk->decode($event->payload)['snapshot']; if ($snapshot['guide']['slug'] !== $slug) { continue; } $history[] = ['id' => $event->id, 'revision' => $snapshot['revision'], 'at' => $snapshot['at'], 'edition' => $snapshot['guide']['version'], 'order' => $event->event_order]; if (count($history) === 26) { break; } } $context = app(InvestorContext::class)->state($case); $unavailable = []; if ($working && $saved) { foreach ($saved['instruments'] as $code) { if (! isset($guide['instruments'][$code])) { $unavailable[] = $saved['guide']['instruments'][$code]['title']; } } } return ['guide' => $guide, 'instruments' => $selected, 'note' => $saved['note'] ?? '', 'saved' => $saved, 'case' => ['id' => $id, 'title' => $case->title, 'version' => (int) $case->version], 'canWrite' => ! $case->closed_at && $this->desk->owns($actor, $case), 'historical' => ! empty($v['revision']), 'olderEdition' => $current === null || $guide['sha256'] !== $current['sha256'], 'currentAvailable' => $current !== null, 'workingCopy' => $working, 'contextChanged' => $saved && ($saved['context']['revision'] !== $context['revision'] || $saved['context']['dependency_hash'] !== ($context['dependency_hash'] ?? null)), 'unavailableInstruments' => $unavailable, 'history' => array_slice($history, 0, 25), 'historyNext' => count($history) > 25 ? $history[24]['order'] : null]; } /** @param array $input * @param array $guide * @return array */ private function reference(array $input, array $guide): array { $v = Validator::make($input, ['sha256' => ['required', Rule::in([$guide['sha256']])], 'note' => 'nullable|string|max:3000'])->validate(); return ['sha256' => $v['sha256'], 'note' => $v['note'] ?? '', 'instruments' => $this->choices($input, $guide)]; } /** @param array $input * @return array */ public function save(User $actor, string $id, string $slug, array $input): array { $this->desk->case($actor, $id, true); $v = Validator::make($input, ['version' => 'required|integer|min:1', 'operation_key' => 'required|uuid'])->validate(); return DB::transaction(function () use ($actor, $id, $slug, $input, $v) { $case = $this->desk->case($actor, $id, true, true); foreach (DB::table('desk_events')->where('case_id', $id)->where('action', self::EVENT)->orderByDesc('event_order')->cursor() as $event) { $data = $this->desk->decode($event->payload); if ($data['operation_key'] === $v['operation_key']) { abort_unless($data['snapshot']['guide']['slug'] === $slug, 409); $reference = $this->reference($input, $data['snapshot']['guide']); $hash = hash('sha256', json_encode([(int) $v['version'], $slug, $reference], JSON_THROW_ON_ERROR)); abort_unless(hash_equals($data['request_hash'], $hash), 409, 'Esta operação já guardou uma referência diferente.'); return ['saved' => true, 'replayed' => true, 'revision' => $data['snapshot']['revision']]; } } $guide = $this->guide($slug); abort_unless(($input['sha256'] ?? null) === $guide['sha256'], 409, 'O guia mudou. Conserve a nota e abra a edição actual para uma nova referência.'); $reference = $this->reference($input, $guide); abort_if($case->closed_at || (int) $case->version !== (int) $v['version'], 409, 'O dossier mudou ou foi fechado. Os dados continuam nesta página; confira a edição guardada antes de tentar novamente.'); $payload = $this->desk->decode($case->payload); $previous = $payload['investment_guides'][$slug] ?? null; $state = app(InvestorContext::class)->state($case); $context = ['revision' => $state['revision'], 'dependency_hash' => $state['dependency_hash'] ?? null]; if ($previous && $previous['guide'] === $guide && $previous['instruments'] === $reference['instruments'] && $previous['note'] === $reference['note'] && $previous['context'] === $context) { return ['saved' => true, 'unchanged' => true, 'revision' => $previous['revision']]; } $snapshot = ['revision' => (int) ($previous['revision'] ?? 0) + 1, 'guide' => $guide, 'instruments' => $reference['instruments'], 'note' => $reference['note'], 'context' => $context, 'scope' => 'dossier_reference', 'at' => now()->toIso8601String(), 'actor_id' => $actor->id]; $payload['investment_guides'][$slug] = $snapshot; $case->version++; DB::table('desk_cases')->where('id', $id)->update(['payload' => $this->desk->encode($payload), 'version' => $case->version, 'updated_at' => now()]); $hash = hash('sha256', json_encode([(int) $v['version'], $slug, $reference], JSON_THROW_ON_ERROR)); $this->desk->event($case, $actor, self::EVENT, ['operation_key' => $v['operation_key'], 'request_hash' => $hash, 'snapshot' => $snapshot]); return ['saved' => true, 'replayed' => false, 'revision' => $snapshot['revision']]; }); } }