Files
Investidor-V2/app/Services/SimulationFormDrafts.php
T
nala 4f35313d06
tests / ci (push) Canceled after 0s
novas funcionalidades dos formulario
2026-10-02 11:13:37 +02:00

163 lines
7.1 KiB
PHP

<?php
namespace App\Services;
use App\Models\SimulationFormDraft;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\HttpException;
final class SimulationFormDrafts
{
/** @param array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>} $data */
public function create(array $data, Request $request, string $key): array
{
[$actorId, $tokenHash] = $this->context($request);
$scope = $actorId === null ? 'anonymous:'.$tokenHash : 'user:'.$actorId;
$keyHash = hash_hmac('sha256', strtolower($key), (string) config('app.key'));
$canonicalPayload = json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$payloadHash = hash('sha256', $canonicalPayload);
return DB::transaction(function () use ($actorId, $tokenHash, $scope, $keyHash, $payloadHash, $canonicalPayload, $data): array {
$this->setContext($actorId, $tokenHash);
DB::select('select pg_advisory_xact_lock(261010, hashtext(?))', [$scope]);
$query = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate();
$query = $actorId === null ? $query->where('anonymous_token_hash', $tokenHash) : $query->where('owner_user_id', $actorId);
$draft = $query->first();
if ($draft) {
if (hash_equals($draft->create_key_hash, $keyHash)) {
abort_unless(hash_equals($draft->create_payload_hash, $payloadHash), 409, 'Idempotency-Key já foi utilizada com outros dados.');
return ['draft' => $draft, 'replayed' => true];
}
throw new HttpException(409, 'Já existe um rascunho activo. Carregue-o antes de guardar novas alterações.');
}
$draft = SimulationFormDraft::query()->create([
'id' => (string) Str::uuid(), 'owner_user_id' => $actorId, 'anonymous_token_hash' => $tokenHash,
'version_no' => 1, 'create_key_hash' => $keyHash, 'create_payload_hash' => $payloadHash, 'snapshot_sha256' => $payloadHash,
'personal_snapshot' => SimulationFormDraft::encryptSnapshot($data['personal_snapshot']),
'economic_snapshot' => SimulationFormDraft::encryptSnapshot($data['economic_snapshot']),
]);
return ['draft' => $draft, 'replayed' => false];
}, 3);
}
/** @param array{personal_snapshot: array<string,mixed>, economic_snapshot: array<string,mixed>} $data */
public function update(string $id, array $data, Request $request): SimulationFormDraft
{
[$actorId, $tokenHash] = $this->context($request);
return DB::transaction(function () use ($id, $data, $request, $actorId, $tokenHash): SimulationFormDraft {
$this->setContext($actorId, $tokenHash);
$draft = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate()->findOrFail($id);
$this->requireMatch($request, $draft);
$canonicalPayload = json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
$draft->update([
'personal_snapshot' => SimulationFormDraft::encryptSnapshot($data['personal_snapshot']),
'economic_snapshot' => SimulationFormDraft::encryptSnapshot($data['economic_snapshot']),
'snapshot_sha256' => hash('sha256', $canonicalPayload), 'version_no' => $draft->version_no + 1,
]);
return $draft->refresh();
}, 3);
}
public function current(Request $request): ?SimulationFormDraft
{
[$actorId, $tokenHash] = $this->context($request);
return DB::transaction(function () use ($actorId, $tokenHash): ?SimulationFormDraft {
$this->setContext($actorId, $tokenHash);
$query = SimulationFormDraft::query()->whereNull('deleted_at');
return ($actorId === null ? $query->where('anonymous_token_hash', $tokenHash) : $query->where('owner_user_id', $actorId))->first();
});
}
public function find(string $id, Request $request): SimulationFormDraft
{
[$actorId, $tokenHash] = $this->context($request);
return DB::transaction(function () use ($id, $actorId, $tokenHash): SimulationFormDraft {
$this->setContext($actorId, $tokenHash);
return SimulationFormDraft::query()->whereNull('deleted_at')->findOrFail($id);
});
}
public function delete(string $id, Request $request): void
{
[$actorId, $tokenHash] = $this->context($request);
DB::transaction(function () use ($id, $request, $actorId, $tokenHash): void {
$this->setContext($actorId, $tokenHash);
$draft = SimulationFormDraft::query()->whereNull('deleted_at')->lockForUpdate()->findOrFail($id);
$this->requireMatch($request, $draft);
$draft->update(['deleted_at' => now()]);
}, 3);
}
/** @return array<string,mixed> */
public function present(SimulationFormDraft $draft): array
{
return [
'id' => $draft->id,
'version_no' => $draft->version_no,
'snapshot_sha256' => $draft->snapshot_sha256,
'personal_snapshot' => SimulationFormDraft::decryptSnapshot($draft->personal_snapshot),
'economic_snapshot' => SimulationFormDraft::decryptSnapshot($draft->economic_snapshot),
'updated_at' => $draft->updated_at,
];
}
public function etag(SimulationFormDraft $draft): string
{
return '"sfd-'.$draft->id.'-v'.$draft->version_no.'"';
}
/** @return array{0:int|null,1:string|null} */
private function context(Request $request): array
{
$actorId = $request->user()?->id;
$token = $request->header('X-Simulation-Token');
abort_unless($actorId !== null || (is_string($token) && strlen($token) >= 32 && strlen($token) <= 256), 422,
'X-Simulation-Token opaco é obrigatório para um rascunho anónimo.');
return [$actorId, $actorId === null ? hash_hmac('sha256', $token, (string) config('app.key')) : null];
}
private function setContext(?int $actorId, ?string $tokenHash): void
{
DB::select("select set_config('app.actor_user_id', ?, true), set_config('app.simulation_token_hash', ?, true)", [(string) ($actorId ?? ''), (string) ($tokenHash ?? '')]);
}
private function requireMatch(Request $request, SimulationFormDraft $draft): void
{
$provided = $request->header('If-Match');
if (! $provided) {
throw new HttpException(428, 'If-Match é obrigatório.');
}
if (! hash_equals($this->etag($draft), trim($provided))) {
throw new HttpException(412, 'O rascunho foi alterado desde a última leitura.');
}
}
private function canonical(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
if (! array_is_list($value)) {
ksort($value);
}
foreach ($value as $key => $item) {
$value[$key] = $this->canonical($item);
}
return $value;
}
}