Files
Investidor-V2/app/Services/OpportunityCuration.php
T

133 lines
6.8 KiB
PHP

<?php
namespace App\Services;
use App\Models\Opportunity;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class OpportunityCuration
{
public function __construct(private PortalProfileAccess $access, private OpportunityManagement $management) {}
public function allowed(User $actor, Opportunity $record): bool
{
if (! $this->management->installed() || ! $this->access->canView($actor, $record)) {
return false;
}
if ($record->owner_user_id === null) {
return $this->access->legacyWriteAllowed($actor);
}
return $this->access->scoped($actor, 'publisher', $record->organization_id, $record->opportunity_category)
&& $this->access->operational($actor, 'publish', $record->id) && ! $this->access->conflict($actor, $record)
&& (int) data_get($record->workflow_context, 'approval.actor_id') !== $actor->id
&& (! data_get($record->workflow_context, 'publication_assignment.assignee_user_id')
|| (int) data_get($record->workflow_context, 'publication_assignment.assignee_user_id') === $actor->id);
}
private function authorize(User $actor, Opportunity $record): void
{
abort_unless($this->allowed($actor, $record), 403);
if ($record->owner_user_id !== null) {
app(OpportunityProfileWorkflow::class)->authorizeEditorialCuration($actor, $record);
}
}
/** @return array<string,mixed> */
public function state(User $actor, int $id, int $page = 1): array
{
$record = Opportunity::findOrFail($id);
$this->authorize($actor, $record);
$history = DB::table('audit_events')->where('source_system', 'opportunity-curation')->where('after_data->record_id', $id)
->orderByDesc('id')->paginate(25, ['id', 'actor_user_id', 'occurred_at', 'after_data'], 'page', $page)->through(function ($row) {
$data = json_decode($row->after_data, true, 512, JSON_THROW_ON_ERROR);
return ['id' => $row->id, 'actor' => $row->actor_user_id, 'at' => $row->occurred_at,
'featured' => $data['featured'], 'reason' => Crypt::decryptString($data['reason'])];
});
return ['record' => ['id' => $record->id, 'title' => $record->title, 'featured' => $record->is_featured],
'etag' => $this->management->etag($record), 'history' => $history];
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function preview(User $actor, int $id, array $input): array
{
$data = $this->validate($input);
$record = Opportunity::findOrFail($id);
$this->authorize($actor, $record);
$this->check($record, $data);
return ['hash' => $this->hash($actor, $id, $data), 'title' => $record->title,
'before' => $record->is_featured, 'featured' => $data['featured'], 'reason' => $data['reason']];
}
/** @param array<string,mixed> $input
* @return array<string,mixed>
*/
public function confirm(User $actor, int $id, array $input): array
{
$data = $this->validate($input);
Validator::make($input, ['hash' => 'required|string|size:64', 'confirm' => 'required|accepted'])->validate();
abort_unless(hash_equals($this->hash($actor, $id, $data), $input['hash']), 409, 'A conferência mudou; prepare nova prévia.');
return DB::transaction(function () use ($actor, $id, $data) {
$record = Opportunity::whereKey($id)->lockForUpdate()->firstOrFail();
$this->authorize($actor, $record);
$receipt = DB::table('audit_events')->where('source_system', 'opportunity-curation')->where('after_data->record_id', $id)
->where('after_data->client_key', $data['client_key'])->first(['actor_user_id', 'after_data']);
$intent = $this->hash($actor, $id, $data);
if ($receipt) {
$prior = json_decode($receipt->after_data, true, 512, JSON_THROW_ON_ERROR);
abort_unless((int) $receipt->actor_user_id === $actor->id && hash_equals($prior['intent'], $intent), 409, 'Chave já utilizada com outro conteúdo.');
return ['replayed' => true, 'featured' => $record->is_featured, 'etag' => $this->management->etag($record)];
}
$this->check($record, $data);
$before = $record->is_featured;
$record->management_version++;
PortalAudit::save($record, ['is_featured' => $data['featured']], $actor->id);
DB::table('audit_events')->insert(['source_system' => 'opportunity-curation', 'entity_type' => 'opportunities',
'actor_user_id' => $actor->id, 'action' => 'featured_changed', 'data_classification' => 'restricted',
'before_data' => json_encode(['record_id' => $id, 'featured' => $before], JSON_THROW_ON_ERROR),
'after_data' => json_encode(['record_id' => $id, 'featured' => $record->is_featured, 'version' => $record->management_version,
'reason' => Crypt::encryptString($data['reason']), 'client_key' => $data['client_key'], 'intent' => $intent], JSON_THROW_ON_ERROR)]);
return ['replayed' => false, 'featured' => $record->is_featured, 'etag' => $this->management->etag($record)];
});
}
/** @param array<string,mixed> $input
* @return array{etag:string,client_key:string,featured:bool,reason:string}
*/
private function validate(array $input): array
{
$data = Validator::make($input, ['etag' => 'required|string|size:64', 'client_key' => 'required|uuid',
'featured' => 'required|boolean', 'reason' => 'required|string|min:20|max:1000'])->validate();
return ['etag' => $data['etag'], 'client_key' => $data['client_key'],
'featured' => (bool) $data['featured'], 'reason' => $data['reason']];
}
/** @param array{etag:string,client_key:string,featured:bool,reason:string} $data */
private function check(Opportunity $record, array $data): void
{
abort_unless(hash_equals($this->management->etag($record), $data['etag']), 409, 'A oportunidade mudou; recupere a versão actual antes de confirmar.');
abort_if($record->is_featured === $data['featured'], 422, 'O estado de destaque já corresponde à escolha.');
if ($data['featured']) {
abort_unless(Opportunity::published()->whereKey($record->id)->exists(), 409, 'Só uma oportunidade actualmente pública pode entrar em destaque.');
}
}
/** @param array{etag:string,client_key:string,featured:bool,reason:string} $data */
private function hash(User $actor, int $id, array $data): string
{
return hash_hmac('sha256', json_encode([$actor->id, $id, $data], JSON_THROW_ON_ERROR), (string) config('app.key'));
}
}