118 lines
6.1 KiB
PHP
118 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Lead;
|
|
use App\Models\User;
|
|
use Illuminate\Contracts\Encryption\DecryptException;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Validator;
|
|
|
|
final class CrmTaskPreparations
|
|
{
|
|
public function installed(): bool
|
|
{
|
|
return Schema::hasTable('crm_task_preparations');
|
|
}
|
|
|
|
/** @return array<string,mixed> */
|
|
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<string,mixed> $input
|
|
* @return array<string,mixed> */
|
|
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<string,mixed> $snapshot
|
|
* @return array<string,mixed> */
|
|
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<string,string> */
|
|
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;
|
|
}
|
|
}
|