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

170 lines
10 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
/** Source-versioned references, stored in the existing dossier, not a financing workflow. */
final class InvestmentGuides
{
public const EVENT = 'investment_guide_saved';
public function __construct(private readonly VirtualDesk $desk) {}
/** @return array<string,mixed> */
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<string,mixed> $input
* @return array<string,mixed> */
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<string,mixed> $input
* @param array<string,mixed> $guide
* @return list<string> */
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<string,mixed> $input
* @return array<string,mixed> */
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<string,mixed> $input
* @return array<string,mixed> */
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<string,mixed> $input
* @param array<string,mixed> $guide
* @return array<string,mixed> */
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<string,mixed> $input
* @return array<string,mixed> */
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']];
});
}
}