*/ private array $people = []; /** @var array */ private array $organizations = []; /** @return array */ 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 */ 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|null $snapshot * @return array */ 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> */ 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|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]; } }