442 lines
22 KiB
PHP
442 lines
22 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\ExistingBusiness;
|
|
use App\Models\SimulationFormDraft;
|
|
use App\Models\User;
|
|
use App\Services\Mne\BusinessRepository;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
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
|
|
* @return array{draft: SimulationFormDraft, replayed: bool}
|
|
*/
|
|
public function create(array $data, Request $request, string $key): array
|
|
{
|
|
[$actorId, $tokenHash] = $this->context($request);
|
|
$data = SimulationFormDraftPrivacyPolicy::apply($data, $actorId !== null);
|
|
$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, $data): array {
|
|
$this->setContext($actorId, $tokenHash);
|
|
DB::select('select pg_advisory_xact_lock(261010, hashtext(?))', [$scope]);
|
|
|
|
if ($actorId === null) {
|
|
SimulationFormDraft::query()->whereNull('owner_user_id')->where('anonymous_token_hash', $tokenHash)
|
|
->whereNull('deleted_at')->where('expires_at', '<=', now())->lockForUpdate()
|
|
->update(['deleted_at' => now()]);
|
|
}
|
|
|
|
$draft = $this->ownedQuery($actorId, $tokenHash)->lockForUpdate()->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']),
|
|
'expires_at' => $actorId === null ? now()->addDays(30) : null,
|
|
]);
|
|
|
|
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);
|
|
$data = SimulationFormDraftPrivacyPolicy::apply($data, $actorId !== null);
|
|
|
|
return DB::transaction(function () use ($id, $data, $request, $actorId, $tokenHash): SimulationFormDraft {
|
|
$this->setContext($actorId, $tokenHash);
|
|
$draft = $this->ownedQuery($actorId, $tokenHash)->lockForUpdate()->findOrFail($id);
|
|
$this->requireMatch($request, $draft);
|
|
$previousEconomic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
|
if (($data['economic_snapshot']['reuse_simulation'] ?? null) === 'no') {
|
|
unset($data['economic_snapshot']['simulation_sources']);
|
|
} elseif (is_array($previousEconomic['simulation_sources'] ?? null)) {
|
|
$data['economic_snapshot']['simulation_sources'] = $previousEconomic['simulation_sources'];
|
|
}
|
|
$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,
|
|
'expires_at' => $actorId === null ? now()->addDays(30) : null,
|
|
]);
|
|
|
|
return $draft->refresh();
|
|
}, 3);
|
|
}
|
|
|
|
public function renew(string $id, Request $request): SimulationFormDraft
|
|
{
|
|
[$actorId, $tokenHash] = $this->context($request);
|
|
|
|
return DB::transaction(function () use ($id, $request, $actorId, $tokenHash): SimulationFormDraft {
|
|
$this->setContext($actorId, $tokenHash);
|
|
$draft = $this->ownedQuery($actorId, $tokenHash)->lockForUpdate()->findOrFail($id);
|
|
$this->requireMatch($request, $draft);
|
|
$draft->update(['expires_at' => $actorId === null ? now()->addDays(30) : null]);
|
|
|
|
return $draft->refresh();
|
|
}, 3);
|
|
}
|
|
|
|
/** @param array<string, mixed> $sourceSnapshot
|
|
* @return array{draft: SimulationFormDraft, replayed: bool}
|
|
*/
|
|
public function attachSimulation(string $id, array $sourceSnapshot, Request $request): array
|
|
{
|
|
[$actorId, $tokenHash] = $this->context($request);
|
|
$sourceId = (string) ($sourceSnapshot['simulation_id'] ?? '');
|
|
|
|
return DB::transaction(function () use ($id, $sourceSnapshot, $sourceId, $request, $actorId, $tokenHash): array {
|
|
$this->setContext($actorId, $tokenHash);
|
|
$draft = $this->ownedQuery($actorId, $tokenHash)->lockForUpdate()->findOrFail($id);
|
|
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
|
abort_unless(isset($economic['purpose']) && $economic['purpose'] !== 'simulate_only', 422, 'Escolha uma finalidade de investimento antes de converter a simulação.');
|
|
$sources = is_array($economic['simulation_sources'] ?? null) ? $economic['simulation_sources'] : [];
|
|
foreach ($sources as $source) {
|
|
if (is_array($source) && ($source['simulation_id'] ?? null) === $sourceId) {
|
|
return ['draft' => $draft, 'replayed' => true];
|
|
}
|
|
}
|
|
|
|
$this->requireMatch($request, $draft);
|
|
$sources[] = $sourceSnapshot;
|
|
$economic['simulation_sources'] = $sources;
|
|
$economic['reuse_simulation'] = 'yes';
|
|
$personal = SimulationFormDraft::decryptSnapshot($draft->personal_snapshot);
|
|
$canonicalPayload = json_encode($this->canonical([
|
|
'personal_snapshot' => $personal,
|
|
'economic_snapshot' => $economic,
|
|
]), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
|
$draft->update([
|
|
'economic_snapshot' => SimulationFormDraft::encryptSnapshot($economic),
|
|
'snapshot_sha256' => hash('sha256', $canonicalPayload),
|
|
'version_no' => $draft->version_no + 1,
|
|
'expires_at' => $actorId === null ? now()->addDays(30) : null,
|
|
]);
|
|
|
|
$plainToken = $request->header('X-Simulation-Token');
|
|
$auditTokenHash = is_string($plainToken) && $plainToken !== ''
|
|
? hash_hmac('sha256', $plainToken, (string) config('app.key'))
|
|
: null;
|
|
$this->setContext($actorId, $auditTokenHash ?? $tokenHash);
|
|
DB::table('simulation_audit_events')->insert([
|
|
'simulation_id' => $sourceId,
|
|
'actor_user_id' => $actorId,
|
|
'action' => 'simulation.attached_to_draft',
|
|
'correlation_id' => $draft->id,
|
|
'data' => json_encode(['draft_id' => $draft->id, 'report_sha256' => $sourceSnapshot['report_sha256']], JSON_THROW_ON_ERROR),
|
|
'occurred_at' => now(),
|
|
]);
|
|
$this->setContext($actorId, $tokenHash);
|
|
|
|
return ['draft' => $draft->refresh(), 'replayed' => false];
|
|
}, 3);
|
|
}
|
|
|
|
public function current(Request $request): ?SimulationFormDraft
|
|
{
|
|
[$actorId, $tokenHash] = $this->context($request);
|
|
|
|
return DB::transaction(function () use ($actorId, $tokenHash): ?SimulationFormDraft {
|
|
$this->setContext($actorId, $tokenHash);
|
|
|
|
return $this->ownedQuery($actorId, $tokenHash)->orderByDesc('updated_at')->first();
|
|
});
|
|
}
|
|
|
|
public function forgetCurrent(Request $request): void
|
|
{
|
|
[$actorId, $tokenHash] = $this->context($request);
|
|
abort_unless($actorId === null && is_string($tokenHash), 422);
|
|
|
|
DB::transaction(function () use ($tokenHash): void {
|
|
$this->setContext(null, $tokenHash);
|
|
SimulationFormDraft::query()->whereNull('owner_user_id')->where('anonymous_token_hash', $tokenHash)
|
|
->whereNull('deleted_at')->lockForUpdate()->update(['deleted_at' => now()]);
|
|
}, 3);
|
|
}
|
|
|
|
/** @return list<array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}> */
|
|
public function ownedDrafts(User $actor): array
|
|
{
|
|
return DB::transaction(function () use ($actor): array {
|
|
$this->setContext($actor->id, null);
|
|
|
|
return array_values($this->ownedQuery($actor->id, null)->orderByDesc('updated_at')->get()
|
|
->map(fn (SimulationFormDraft $draft): array => $this->summary($draft))->all());
|
|
});
|
|
}
|
|
|
|
/** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int}|null */
|
|
public function claimPreview(Request $request): ?array
|
|
{
|
|
abort_unless($request->user() instanceof User, 401);
|
|
$tokenHash = $this->anonymousTokenHash($request);
|
|
|
|
return DB::transaction(function () use ($request, $tokenHash): ?array {
|
|
$this->setContext($request->user()->id, $tokenHash);
|
|
$draft = SimulationFormDraft::query()->whereNull('owner_user_id')->where('anonymous_token_hash', $tokenHash)
|
|
->whereNull('deleted_at')->where('expires_at', '>', now())->first();
|
|
|
|
return $draft ? $this->summary($draft) : null;
|
|
});
|
|
}
|
|
|
|
/** @return array{draft:SimulationFormDraft,choice:string,replayed?:bool} */
|
|
public function claim(Request $request, string $choice, ?string $existingDraftId = null): array
|
|
{
|
|
$actor = $request->user();
|
|
abort_unless($actor instanceof User && $actor->hasVerifiedEmail(), 403);
|
|
$tokenHash = $this->anonymousTokenHash($request);
|
|
|
|
return DB::transaction(function () use ($actor, $tokenHash, $choice, $existingDraftId): array {
|
|
$this->setContext($actor->id, $tokenHash);
|
|
$anonymous = SimulationFormDraft::query()->whereNull('owner_user_id')->where('anonymous_token_hash', $tokenHash)
|
|
->whereNull('deleted_at')->where('expires_at', '>', now())->lockForUpdate()->first();
|
|
if (! $anonymous && $choice === 'save_as_new') {
|
|
$claimed = $this->ownedQuery($actor->id, null)->where('claimed_token_hash', $tokenHash)->lockForUpdate()->first();
|
|
if ($claimed) {
|
|
return ['draft' => $claimed, 'choice' => $choice, 'replayed' => true];
|
|
}
|
|
}
|
|
abort_unless($anonymous !== null, 404, 'O rascunho anónimo expirou ou já não está disponível.');
|
|
|
|
if ($choice === 'continue_existing') {
|
|
abort_unless(is_string($existingDraftId) && Str::isUuid($existingDraftId), 422);
|
|
$existing = $this->ownedQuery($actor->id, null)->whereKey($existingDraftId)->firstOrFail();
|
|
|
|
return ['draft' => $existing, 'choice' => $choice];
|
|
}
|
|
|
|
abort_unless($choice === 'save_as_new', 422);
|
|
$anonymous->update(['owner_user_id' => $actor->id, 'anonymous_token_hash' => null, 'claimed_token_hash' => $tokenHash, 'expires_at' => null,
|
|
'version_no' => $anonymous->version_no + 1]);
|
|
|
|
return ['draft' => $anonymous->refresh(), 'choice' => $choice, 'replayed' => false];
|
|
}, 3);
|
|
}
|
|
|
|
/** @return list<array{id:string,title:string,modality:string,status:string}> */
|
|
public function linkableBusinesses(User $actor): array
|
|
{
|
|
return DB::transaction(function () use ($actor): array {
|
|
$this->setContext($actor->id, null);
|
|
|
|
$businesses = app(BusinessRepository::class)->query()->where('owner_user_id', $actor->id)->whereNull('deleted_at')
|
|
->orderByDesc('updated_at')->get()->filter(fn (ExistingBusiness $business): bool => Gate::forUser($actor)->allows('owns', $business))
|
|
->map(fn (ExistingBusiness $business): array => ['id' => $business->id,
|
|
'title' => (string) ($business->public_title ?: ($business->payload['title'] ?? 'Negócio sem título')),
|
|
'modality' => $business->modality, 'status' => $business->status])->values()->all();
|
|
|
|
return array_values($businesses);
|
|
});
|
|
}
|
|
|
|
/** @return array{id:string,business_id:string,title:string,linked_at:string}|array{replayed:true} */
|
|
public function linkBusiness(User $actor, string $draftId, string $businessId): array
|
|
{
|
|
return DB::transaction(function () use ($actor, $draftId, $businessId): array {
|
|
$this->setContext($actor->id, null);
|
|
$draft = $this->ownedQuery($actor->id, null)->whereKey($draftId)->lockForUpdate()->firstOrFail();
|
|
$business = app(BusinessRepository::class)->find($businessId, true);
|
|
Gate::forUser($actor)->authorize('owns', $business);
|
|
$active = DB::table('simulation_draft_business_links')->where('draft_id', $draft->id)->whereNull('unlinked_at')->lockForUpdate()->first();
|
|
if ($active) {
|
|
abort_unless($active->business_id === $business->id, 409, 'Desassocie o negócio actual antes de escolher outro.');
|
|
|
|
return ['replayed' => true];
|
|
}
|
|
$id = (string) Str::uuid();
|
|
DB::table('simulation_draft_business_links')->insert(['id' => $id, 'draft_id' => $draft->id,
|
|
'business_id' => $business->id, 'owner_user_id' => $actor->id, 'linked_by' => $actor->id,
|
|
'linked_at' => now(), 'created_at' => now(), 'updated_at' => now()]);
|
|
|
|
return ['id' => $id, 'business_id' => $business->id,
|
|
'title' => (string) ($business->public_title ?: ($business->payload['title'] ?? 'Negócio sem título')),
|
|
'linked_at' => now()->toISOString()];
|
|
}, 3);
|
|
}
|
|
|
|
/** @return array{unlinked:bool} */
|
|
public function unlinkBusiness(User $actor, string $draftId): array
|
|
{
|
|
return DB::transaction(function () use ($actor, $draftId): array {
|
|
$this->setContext($actor->id, null);
|
|
$draft = $this->ownedQuery($actor->id, null)->whereKey($draftId)->lockForUpdate()->firstOrFail();
|
|
$link = DB::table('simulation_draft_business_links')->where('draft_id', $draft->id)->whereNull('unlinked_at')->lockForUpdate()->first();
|
|
if (! $link) {
|
|
return ['unlinked' => false];
|
|
}
|
|
DB::table('simulation_draft_business_links')->where('id', $link->id)->update(['unlinked_at' => now(),
|
|
'unlinked_by' => $actor->id, 'updated_at' => now()]);
|
|
|
|
return ['unlinked' => true];
|
|
}, 3);
|
|
}
|
|
|
|
/** @return array<string,mixed>|null */
|
|
public function activeBusinessLink(User $actor, string $draftId): ?array
|
|
{
|
|
return DB::transaction(function () use ($actor, $draftId): ?array {
|
|
$this->setContext($actor->id, null);
|
|
$this->ownedQuery($actor->id, null)->whereKey($draftId)->firstOrFail();
|
|
$row = DB::table('simulation_draft_business_links')->where('draft_id', $draftId)
|
|
->where('owner_user_id', $actor->id)->whereNull('unlinked_at')->first();
|
|
if (! $row) {
|
|
return null;
|
|
}
|
|
$business = app(BusinessRepository::class)->find($row->business_id);
|
|
if (! Gate::forUser($actor)->allows('owns', $business)) {
|
|
return ['id' => $row->id, 'business_id' => $row->business_id,
|
|
'title' => 'Negócio indisponível para esta conta',
|
|
'linked_at' => Carbon::parse($row->linked_at)->toISOString()];
|
|
}
|
|
|
|
return ['id' => $row->id, 'business_id' => $row->business_id,
|
|
'title' => (string) ($business->public_title ?: ($business->payload['title'] ?? 'Negócio sem título')),
|
|
'linked_at' => Carbon::parse($row->linked_at)->toISOString()];
|
|
});
|
|
}
|
|
|
|
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 $this->ownedQuery($actorId, $tokenHash)->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 = $this->ownedQuery($actorId, $tokenHash)->lockForUpdate()->findOrFail($id);
|
|
$this->requireMatch($request, $draft);
|
|
$draft->update(['deleted_at' => now()]);
|
|
}, 3);
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
public function present(SimulationFormDraft $draft, bool $includePersonal): array
|
|
{
|
|
$economic = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
|
$simulationSources = is_array($economic['simulation_sources'] ?? null) ? $economic['simulation_sources'] : [];
|
|
unset($economic['simulation_sources']);
|
|
|
|
return [
|
|
'id' => $draft->id,
|
|
'version_no' => $draft->version_no,
|
|
'snapshot_sha256' => $draft->snapshot_sha256,
|
|
'personal_snapshot' => $includePersonal ? SimulationFormDraft::decryptSnapshot($draft->personal_snapshot) : [],
|
|
'economic_snapshot' => $economic,
|
|
'simulation_sources' => $simulationSources,
|
|
'updated_at' => $draft->updated_at,
|
|
'expires_at' => $draft->expires_at?->toISOString(),
|
|
];
|
|
}
|
|
|
|
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 anonymousTokenHash(Request $request): string
|
|
{
|
|
$token = $request->header('X-Simulation-Token');
|
|
abort_unless(is_string($token) && strlen($token) >= 32 && strlen($token) <= 256, 422, 'A recuperação do rascunho expirou. Reinicie pelo mesmo navegador ou dispositivo.');
|
|
|
|
return hash_hmac('sha256', $token, (string) config('app.key'));
|
|
}
|
|
|
|
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.');
|
|
}
|
|
}
|
|
|
|
/** @return Builder<SimulationFormDraft> */
|
|
private function ownedQuery(?int $actorId, ?string $tokenHash): Builder
|
|
{
|
|
$query = SimulationFormDraft::query()->whereNull('deleted_at');
|
|
|
|
return $actorId === null
|
|
? $query->whereNull('owner_user_id')->where('anonymous_token_hash', $tokenHash)->where('expires_at', '>', now())
|
|
: $query->where('owner_user_id', $actorId)->whereNull('anonymous_token_hash');
|
|
}
|
|
|
|
/** @return array{id:string,title:string,updated_at:string|null,expires_at:string|null,version_no:int} */
|
|
private function summary(SimulationFormDraft $draft): array
|
|
{
|
|
$snapshot = SimulationFormDraft::decryptSnapshot($draft->economic_snapshot);
|
|
|
|
return ['id' => $draft->id, 'title' => (string) ($snapshot['project_title'] ?? 'Projecto sem título'),
|
|
'updated_at' => $draft->updated_at?->toISOString(), 'expires_at' => $draft->expires_at?->toISOString(),
|
|
'version_no' => (int) $draft->version_no];
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|