170 lines
10 KiB
PHP
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']];
|
|
});
|
|
}
|
|
}
|