133 lines
6.8 KiB
PHP
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'));
|
|
}
|
|
}
|