, economic_snapshot: array} $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.'); } if ($case) { $data['economic_snapshot'] = $this->landContext($data['economic_snapshot'], $case); } $snapshotHash = hash('sha256', json_encode($this->canonical($data), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE)); $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' => $snapshotHash, '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, economic_snapshot: array} $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); $data['economic_snapshot'] = $this->preserveInactiveLandStatus($draft, $data['economic_snapshot'], $previousEconomic, $actorId); 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 $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 */ 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 */ 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|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 */ 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 */ 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 $query * @return Builder */ 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; } /** * @param array $economic * @param array $previous * @return array */ private function preserveInactiveLandStatus(SimulationFormDraft $draft, array $economic, array $previous, ?int $actorId): array { if (! $actorId || ! config('investor-path.enabled') || ! app(VirtualDesk::class)->installed()) { return $economic; } // Use the real owner-bound dossier even if a request omits its context parameter. $case = DB::table('desk_cases')->where('simulation_id', $draft->id)->where('user_id', $actorId)->first(); if (! $case) { return $economic; } return $this->landContext($economic, $case, $previous); } /** * @param array $economic * @param array|null $previous * @return array */ private function landContext(array $economic, \stdClass $case, ?array $previous = null): array { $snapshot = json_decode($economic['context_applicability_snapshot'] ?? $previous['context_applicability_snapshot'] ?? '', true); $key = $economic['context_opportunity'] ?? $snapshot['consumer_opportunity'] ?? null; $key = app(InvestmentConsumerScope::class)->key(app(InvestorContext::class)->state($case), is_string($key) ? $key : null); $decision = app(InvestorContext::class)->landPreparation($case, $key); if ($decision['need'] !== 'yes' && $previous !== null) { if (array_key_exists('land_status', $previous)) { $economic['land_status'] = $previous['land_status']; } else { unset($economic['land_status']); } if (isset($previous['selection_labels']['land_status'])) { $economic['selection_labels']['land_status'] = $previous['selection_labels']['land_status']; } else { unset($economic['selection_labels']['land_status']); } } $snapshot = json_decode($economic['context_applicability_snapshot'] ?? $previous['context_applicability_snapshot'] ?? '', true); if (! is_array($snapshot) || ($snapshot['version'] ?? null) !== 1) { $snapshot = ['version' => 1, 'context_revision' => app(InvestorContext::class)->state($case)['revision']]; } $snapshot['land_preparation'] = $decision; $snapshot['consumer_opportunity'] = $key; if ($key) { $economic['context_opportunity'] = $key; } $economic['context_applicability_snapshot'] = json_encode($snapshot, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); return $economic; } 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; } }