556 lines
28 KiB
PHP
556 lines
28 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.');
|
|
}
|
|
|
|
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<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);
|
|
$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<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;
|
|
}
|
|
|
|
/**
|
|
* @param array<array-key, mixed> $economic
|
|
* @param array<array-key, mixed> $previous
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
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<array-key, mixed> $economic
|
|
* @param array<array-key, mixed>|null $previous
|
|
* @return array<array-key, mixed>
|
|
*/
|
|
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;
|
|
}
|
|
}
|