Files
Investidor-V2/app/Services/SimulationFormDrafts.php
T
nala 6ddafc95ba
tests / ci (push) Canceled after 0s
oportunidades 4
2026-10-10 07:03:59 +02:00

495 lines
25 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);
$caseId = $request->input('context');
if ($caseId !== null) {
abort_unless(config('investor-path.enabled') && is_string($caseId) && Str::isUuid($caseId), 422, 'Contexto de investimento indisponível.');
abort_unless($request->user()?->hasVerifiedEmail(), 403);
}
$data = SimulationFormDraftPrivacyPolicy::apply($data, $actorId !== null);
$scope = ($actorId === null ? 'anonymous:'.$tokenHash : 'user:'.$actorId).($caseId ? ':case:'.$caseId : '');
$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, $caseId, $request): 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()]);
}
$case = $caseId ? app(VirtualDesk::class)->case($request->user(), $caseId, true, true) : null;
// Shared case access never grants ownership of a private simulator snapshot.
abort_if($case && ((int) $case->user_id !== $actorId || $case->closed_at), 403);
$query = $this->ownedQuery($actorId, $tokenHash);
$draft = $case
? ($case->simulation_id ? $query->whereKey($case->simulation_id)->lockForUpdate()->firstOrFail() : null)
: $this->unscoped($query, $actorId)->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,
]);
if ($case) {
DB::table('desk_cases')->where('id', $case->id)->update(['simulation_id' => $draft->id, 'version' => $case->version + 1, 'updated_at' => now()]);
app(VirtualDesk::class)->event(app(VirtualDesk::class)->case($request->user(), $case->id), $request->user(), 'context_draft_created', ['draft_id' => $draft->id]);
}
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->checkContext($request, $draft);
$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, $request): ?SimulationFormDraft {
$this->setContext($actorId, $tokenHash);
$query = $this->ownedQuery($actorId, $tokenHash);
if ($request->query('context') !== null) {
$id = $request->query('context');
abort_unless(config('investor-path.enabled') && $request->user()?->hasVerifiedEmail() && is_string($id) && Str::isUuid($id), 403);
$case = app(VirtualDesk::class)->case($request->user(), $id, true);
abort_unless((int) $case->user_id === $actorId, 403);
return $case->simulation_id ? $query->whereKey($case->simulation_id)->firstOrFail() : null;
}
return $this->unscoped($query, $actorId)->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, $request): SimulationFormDraft {
$this->setContext($actorId, $tokenHash);
$draft = $this->ownedQuery($actorId, $tokenHash)->findOrFail($id);
$this->checkContext($request, $draft);
return $draft;
});
}
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');
}
/** @param Builder<SimulationFormDraft> $query
* @return Builder<SimulationFormDraft>
*/
private function unscoped(Builder $query, ?int $actorId): Builder
{
if ($actorId !== null && config('investor-path.enabled') && app(VirtualDesk::class)->installed()) {
// The direct tool has its own current draft; context drafts are opened explicitly by ID/context.
$query->whereNotExists(fn ($q) => $q->selectRaw('1')->from('desk_cases')
->whereColumn('desk_cases.simulation_id', 'simulation_form_drafts.id'));
}
return $query;
}
private function checkContext(Request $request, SimulationFormDraft $draft): void
{
$context = $request->input('context');
if ($context === null) {
return;
}
abort_unless(config('investor-path.enabled') && $request->user()?->hasVerifiedEmail() && is_string($context) && Str::isUuid($context), 403);
$case = app(VirtualDesk::class)->case($request->user(), $context, true);
abort_unless((int) $case->user_id === $request->user()->id && $case->simulation_id === $draft->id, 403, 'Rascunho pertencente a outro contexto.');
}
/** @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;
}
}