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

199 lines
11 KiB
PHP

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use stdClass;
class PartnershipCandidacyHistory
{
private const PAGE_SIZE = 10;
/** @var array<int,string|null> */
private array $people = [];
/** @var array<string,string|null> */
private array $organizations = [];
/** @return array<string,mixed> */
public function page(User $actor, int $id, ?string $before = null): array
{
$interest = DB::table('partnership_interests')->where('id', $id)->first();
abort_unless($interest !== null, 404);
$exchange = app(PartnershipExchange::class);
$record = $exchange->find($interest->partnership_id);
$responsible = $exchange->owns($actor, $record, false);
abort_unless($responsible || $exchange->owns($actor, $interest, false), 403, 'Histórico reservado aos intervenientes autorizados desta candidatura.');
$boundary = $before === null ? null : $this->decodeCursor($before, $actor, $id);
$events = DB::table('partnership_events')->where('partnership_id', $record->id)
->whereIn('event_type', ['partnership_interest.submitted.v1', 'partnership_interest.changed.v1'])
->whereRaw("payload->'payload'->>'interest_id' = ?", [(string) $id])
->when($boundary, fn ($query) => $query->where('aggregate_version', '<', $boundary))
->orderByDesc('aggregate_version')->limit(self::PAGE_SIZE + 1)->get(['aggregate_version', 'event_type', 'actor_id', 'occurred_at', 'payload']);
$hasMore = $events->count() > self::PAGE_SIZE;
$page = $events->take(self::PAGE_SIZE);
$entries = $page->map(fn ($event) => $this->entry($event, $actor, $interest, $responsible))->all();
$base = '/parcerias/candidaturas/'.$id.'/historico';
$last = $page->last();
$next = $hasMore && $last ? $base.'?before='.rawurlencode(Crypt::encryptString(json_encode(['actor' => $actor->id, 'interest' => $id, 'before' => (int) $last->aggregate_version], JSON_THROW_ON_ERROR))) : null;
return ['candidacy_id' => $id, 'entries' => $entries, 'next_url' => $next, 'first_url' => $base, 'older_page' => $before !== null,
'partnership_href' => $record->status === 'published' || $responsible ? '/parcerias/'.$record->id : null, 'dashboard_href' => '/parcerias/painel'];
}
private function decodeCursor(string $token, User $actor, int $id): int
{
try {
$cursor = json_decode(Crypt::decryptString($token), true, 512, JSON_THROW_ON_ERROR);
} catch (DecryptException|\JsonException) {
throw ValidationException::withMessages(['before' => 'A referência da página não é válida. Volte ao início do histórico.']);
}
if (! is_array($cursor) || ($cursor['actor'] ?? null) !== $actor->id || ($cursor['interest'] ?? null) !== $id || ! is_int($cursor['before'] ?? null) || $cursor['before'] < 1) {
throw ValidationException::withMessages(['before' => 'A referência da página não pertence a esta candidatura e conta. Volte ao início do histórico.']);
}
return $cursor['before'];
}
/** @return array<string,mixed> */
private function entry(stdClass $event, User $actor, stdClass $interest, bool $responsible): array
{
$envelope = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR);
$data = is_array($envelope) && is_array($envelope['payload'] ?? null) ? $envelope['payload'] : [];
$previous = is_array($data['previous'] ?? null) ? $data['previous'] : null;
$submitted = is_array($data['submitted'] ?? null) ? $data['submitted'] : null;
$decision = $event->event_type === 'partnership_interest.changed.v1';
$after = $submitted;
if ($decision) {
$after = array_replace($previous ?? [], ['version' => $data['interest_version'] ?? null, 'status' => $data['to'] ?? null, 'assessment' => $data['assessment'] ?? null, 'decision_reason' => $data['reason'] ?? null]);
}
$beforeView = $this->snapshot($previous, $actor, $interest, $responsible);
$afterView = $this->snapshot($after, $actor, $interest, $responsible);
$canRead = $responsible || $beforeView['availability'] === 'available' || $afterView['availability'] === 'available';
$version = is_int($data['interest_version'] ?? null) && $data['interest_version'] > 0 ? $data['interest_version'] : null;
if (! $canRead) {
return ['version' => $version, 'restricted' => true, 'action' => null, 'actor_name' => null, 'occurred_at' => null, 'reason' => null, 'previous' => $beforeView, 'after' => $afterView, 'matching' => null, 'partnership_version' => null];
}
$action = $decision ? ($data['action'] ?? null) : 'submit';
return ['version' => $version, 'restricted' => false, 'action' => is_string($action) && in_array($action, ['submit', 'assess', 'clarify', 'shortlist', 'reject', 'connect', 'withdraw'], true) ? $action : null,
'actor_name' => $this->person((int) $event->actor_id), 'occurred_at' => $event->occurred_at, 'reason' => is_string($data['reason'] ?? null) ? $data['reason'] : null,
'previous' => $beforeView, 'after' => $afterView, 'matching' => $afterView['availability'] === 'available' ? $this->matching($data['matching_context'] ?? null) : null,
'partnership_version' => is_int($data['partnership_version'] ?? null) ? $data['partnership_version'] : (is_int($envelope['publication_version'] ?? null) ? $envelope['publication_version'] : null)];
}
/** @param array<string,mixed>|null $snapshot
* @return array<string,mixed>
*/
private function snapshot(?array $snapshot, User $actor, stdClass $interest, bool $responsible): array
{
if ($snapshot === null) {
return ['availability' => 'not_recorded'];
}
$scopeKnown = array_key_exists('organization_id', $snapshot) && ($snapshot['organization_id'] === null || is_string($snapshot['organization_id']) && Str::isUuid($snapshot['organization_id']));
if (! $responsible && ! $scopeKnown) {
return ['availability' => 'scope_unknown'];
}
if (! $responsible && ! app(PartnershipExchange::class)->owns($actor, (object) ['organization_id' => $snapshot['organization_id'], 'user_id' => $interest->user_id], false)) {
return ['availability' => 'restricted'];
}
$organization = $scopeKnown && is_string($snapshot['organization_id']) ? $snapshot['organization_id'] : null;
$payload = is_array($snapshot['payload'] ?? null) ? $snapshot['payload'] : null;
$fields = [];
foreach (['proposal', 'decision_reason'] as $key) {
if (array_key_exists($key, $snapshot) && ($snapshot[$key] === null || is_string($snapshot[$key]))) {
$fields[$key] = $snapshot[$key];
}
}
foreach (['capacity', 'availability', 'contribution', 'conflicts'] as $key) {
if ($payload !== null && array_key_exists($key, $payload) && ($payload[$key] === null || is_string($payload[$key]))) {
$fields[$key] = $payload[$key];
}
}
$consent = $payload !== null && array_key_exists('consent', $payload) ? $payload['consent'] : null;
$knownConsent = in_array($consent, [true, false, 0, 1, '0', '1', 'yes', 'on'], true);
$references = [];
foreach (['sector_id', 'territory_id'] as $key) {
if ($payload !== null && (is_string($payload[$key] ?? null) || is_int($payload[$key] ?? null))) {
$references[$key] = (string) $payload[$key];
}
}
return ['availability' => 'available', 'version' => is_int($snapshot['version'] ?? null) ? $snapshot['version'] : null,
'status' => is_string($snapshot['status'] ?? null) ? $snapshot['status'] : null, 'scope_known' => $scopeKnown, 'organization_id' => $organization,
'organization_name' => $organization ? $this->organization($organization) : null,
'fields' => $fields, 'consent_known' => $knownConsent, 'consent' => $knownConsent ? in_array($consent, [true, 1, '1', 'yes', 'on'], true) : null,
'assessment_recorded' => is_array($snapshot['assessment'] ?? null), 'assessment' => $this->assessment($snapshot['assessment'] ?? null), 'references' => $references,
'complete' => array_key_exists('proposal', $fields) && $scopeKnown && $payload !== null && is_array($snapshot['assessment'] ?? null)];
}
/** @return list<array<string,mixed>> */
private function assessment(mixed $value): array
{
$items = [];
foreach (is_array($value) ? $value : [] as $code => $entry) {
if (! is_array($entry) || ! is_string($code)) {
continue;
}
$label = config('partnerships.criteria.'.$code, $code);
$items[] = ['code' => $code, 'label' => is_string($label) ? $label : $code, 'label_source' => 'current_catalogue',
'state' => is_string($entry['state'] ?? null) ? $entry['state'] : null, 'evidence' => is_string($entry['evidence'] ?? null) ? $entry['evidence'] : null,
'actor_name' => is_int($entry['assessed_by'] ?? null) ? $this->person($entry['assessed_by']) : null, 'assessed_at' => is_string($entry['assessed_at'] ?? null) ? $entry['assessed_at'] : null];
}
return $items;
}
/** @return array<string,mixed>|null */
private function matching(mixed $value): ?array
{
if (! is_array($value)) {
return null;
}
$criteria = [];
foreach (is_array($value['criteria'] ?? null) ? $value['criteria'] : [] as $criterion) {
if (! is_array($criterion) || ! is_string($criterion['code'] ?? null)) {
continue;
}
$projected = ['code' => $criterion['code'], 'mandatory' => is_bool($criterion['mandatory'] ?? null) ? $criterion['mandatory'] : null,
'weight' => is_int($criterion['weight'] ?? null) || is_float($criterion['weight'] ?? null) ? $criterion['weight'] : null];
foreach (['label', 'state', 'evidence', 'explanation'] as $field) {
$projected[$field] = is_string($criterion[$field] ?? null) ? $criterion[$field] : null;
}
$criteria[] = $projected;
}
$matching = ['criteria' => $criteria];
foreach (['algorithm_version', 'weights_hash', 'eligibility'] as $field) {
$matching[$field] = is_string($value[$field] ?? null) ? $value[$field] : null;
}
foreach (['score_min', 'score_max', 'unknown_weight'] as $field) {
$matching[$field] = is_int($value[$field] ?? null) || is_float($value[$field] ?? null) ? $value[$field] : null;
}
return $matching;
}
private function person(int $id): ?string
{
if (! array_key_exists($id, $this->people)) {
$this->people[$id] = DB::table('users')->where('id', $id)->value('name');
}
return $this->people[$id];
}
private function organization(string $id): ?string
{
if (! array_key_exists($id, $this->organizations)) {
$this->organizations[$id] = DB::table('organizations')->where('id', $id)->value('name');
}
return $this->organizations[$id];
}
}