*/ public function view(User $actor, Lead $lead): array { abort_unless(Gate::forUser($actor)->allows('manage-portal'), 403); $row = $this->installed() ? DB::table('crm_task_preparations')->where('actor_id', $actor->id)->where('lead_id', $lead->id)->first() : null; $snapshot = null; $unavailable = false; if ($row) { try { $decoded = json_decode(Crypt::decryptString($row->snapshot), true); $validator = Validator::make(['snapshot' => $decoded], $this->rules()); if ($validator->fails()) { $unavailable = true; } else { $snapshot = $this->normalize($validator->validated()['snapshot']); } } catch (DecryptException) { $unavailable = true; } } return ['installed' => $this->installed(), 'version' => (int) ($row->version ?? 0), 'snapshot' => $snapshot, 'operation_key' => $row->operation_key ?? null, 'updated_at' => $row->updated_at ?? null, 'unavailable' => $unavailable]; } /** @param array $input * @return array */ public function save(User $actor, Lead $lead, array $input): array { abort_unless(Gate::forUser($actor)->allows('manage-portal'), 403); abort_unless($this->installed(), 409, 'A recuperação persistente ainda não está instalada. A criação manual continua disponível.'); $data = Validator::make($input, [...$this->rules(), 'version' => 'required|integer|min:0', 'operation_key' => 'required|uuid'])->validate(); $data['snapshot'] = $this->normalize($data['snapshot']); $key = strtolower($data['operation_key']); $hash = hash('sha256', json_encode(Arr::sortRecursive(['version' => (int) $data['version'], 'snapshot' => $data['snapshot']]), JSON_THROW_ON_ERROR)); return DB::transaction(function () use ($actor, $lead, $data, $key, $hash): array { $lead = Lead::lockForUpdate()->findOrFail($lead->id); $row = DB::table('crm_task_preparations')->where('actor_id', $actor->id)->where('lead_id', $lead->id)->lockForUpdate()->first(); if ($row && $row->operation_key === $key) { abort_unless(hash_equals($row->operation_sha256, $hash), 409, 'Esta tentativa de guardar já foi usada com outros dados.'); return app(CrmTaskCreation::class)->context($actor, $lead); } abort_unless((int) ($row->version ?? 0) === (int) $data['version'], 409, 'A preparação mudou noutro separador. Compare antes de substituir.'); if ($row) { abort_if($this->view($actor, $lead)['unavailable'], 409, 'A preparação guardada não pôde ser recuperada. Os dados anteriores foram preservados.'); } $version = (int) ($row->version ?? 0) + 1; DB::table('crm_task_preparations')->updateOrInsert(['actor_id' => $actor->id, 'lead_id' => $lead->id], [ 'version' => $version, 'operation_key' => $key, 'operation_sha256' => $hash, 'snapshot' => Crypt::encryptString(json_encode($data['snapshot'], JSON_THROW_ON_ERROR)), 'created_at' => $row->created_at ?? now(), 'updated_at' => now(), ]); DB::table('audit_events')->insert(['source_system' => 'portal-crm-task-preparation', 'entity_type' => 'crm_task_preparations', 'entity_id' => $lead->id, 'actor_user_id' => $actor->id, 'action' => 'draft_checkpoint_saved', 'data_classification' => 'restricted', 'after_data' => json_encode(['version' => $version], JSON_THROW_ON_ERROR)]); return app(CrmTaskCreation::class)->context($actor, $lead); }); } /** @param array $snapshot * @return array */ private function normalize(array $snapshot): array { foreach (['title', 'due_at', 'assignee_user_id'] as $field) { $snapshot['generation'][$field] = (int) $snapshot['generation'][$field]; if (is_array($snapshot['pending'])) { $snapshot['pending']['generation'][$field] = (int) $snapshot['pending']['generation'][$field]; } } if (is_array($snapshot['pending'])) { $snapshot['pending']['values']['client_key'] = strtolower($snapshot['pending']['values']['client_key']); } return $snapshot; } /** @return array */ private function rules(): array { $rules = ['snapshot' => 'required|array:values,generation,pending', 'snapshot.values' => 'required|array:title,due_at,assignee_user_id', 'snapshot.generation' => 'required|array:title,due_at,assignee_user_id', 'snapshot.pending' => 'present|nullable|array:values,generation', 'snapshot.pending.values' => 'required_with:snapshot.pending|array:title,due_at,assignee_user_id,client_key', 'snapshot.pending.generation' => 'required_with:snapshot.pending|array:title,due_at,assignee_user_id', 'snapshot.pending.values.client_key' => 'required_with:snapshot.pending|uuid']; foreach (['title' => 200, 'due_at' => 100, 'assignee_user_id' => 20] as $field => $limit) { $rules['snapshot.values.'.$field] = 'present|string|max:'.$limit; $rules['snapshot.generation.'.$field] = 'present|integer|min:0|max:1000000000'; $rules['snapshot.pending.values.'.$field] = 'exclude_without:snapshot.pending|present|string|max:'.$limit; $rules['snapshot.pending.generation.'.$field] = 'exclude_without:snapshot.pending|present|integer|min:0|max:1000000000'; } return $rules; } }