*/ private array $people = []; public function __construct(private PartnershipAgreementProjection $projection) {} /** @return array */ public function page(User $actor, int $id, ?string $before = null): array { $context = app(PartnershipExchange::class)->room($actor, $id); $boundary = $before === null ? null : $this->decode($before, $actor, $context); $events = DB::table('partnership_events')->where('partnership_id', $context['partnership']->id)->where('event_type', 'partnership_agreement.changed.v1') ->whereRaw("payload->'payload'->>'room_id' = ?", [(string) $id]) ->when($boundary, fn ($q) => $q->where('aggregate_version', '<', $boundary))->orderByDesc('aggregate_version')->limit(11)->get(['aggregate_version', 'actor_id', 'occurred_at', 'payload']); $page = $events->take(10); $base = '/parcerias/salas/'.$id.'/historico'; $last = $page->last(); $next = $events->count() > 10 && $last ? $base.'?before='.rawurlencode(Crypt::encryptString(json_encode(['purpose' => 'agreement', 'actor' => $actor->id, 'room' => $id, 'interest' => (int) $context['interest']->id, 'partnership' => (int) $context['partnership']->id, 'before' => (int) $last->aggregate_version], JSON_THROW_ON_ERROR))) : null; return ['room_id' => $id, 'entries' => $page->map(fn ($event) => $this->entry($event, $actor, $context))->all(), 'first_url' => $base, 'next_url' => $next, 'older_page' => $before !== null, 'room_href' => '/parcerias/salas/'.$id, 'dashboard_href' => '/parcerias/painel']; } /** @param array{partnership:stdClass,interest:stdClass,room:stdClass} $context */ private function decode(string $token, User $actor, array $context): int { try { $value = 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($value) || ($value['purpose'] ?? null) !== 'agreement' || ($value['actor'] ?? null) !== $actor->id || ($value['room'] ?? null) !== (int) $context['room']->id || ($value['interest'] ?? null) !== (int) $context['interest']->id || ($value['partnership'] ?? null) !== (int) $context['partnership']->id || ! $this->positive($value['before'] ?? null)) { throw ValidationException::withMessages(['before' => 'A referência da página não pertence a esta sala e conta. Volte ao início do histórico.']); } return $value['before']; } /** @param array{partnership:stdClass,interest:stdClass,room:stdClass} $context * @return array */ private function entry(stdClass $event, User $actor, array $context): array { $envelope = json_decode($event->payload, true, 512, JSON_THROW_ON_ERROR); $data = is_array($envelope) && is_array($envelope['payload'] ?? null) ? $envelope['payload'] : []; $scope = $data['_notification_context'] ?? null; $proponent = is_array($scope) ? $this->scope($scope['partnership'] ?? null) : null; $candidate = is_array($scope) ? $this->scope($scope['interest'] ?? null) : null; $known = is_array($scope) && ($scope['version'] ?? null) === 1 && ($scope['room_id'] ?? null) === (int) $context['room']->id && $proponent !== null && $candidate !== null && ($proponent['id'] ?? null) === (int) $context['partnership']->id && ($candidate['id'] ?? null) === (int) $context['interest']->id; $exchange = app(PartnershipExchange::class); $visible = $known && ($exchange->owns($actor, (object) $proponent, false) || $exchange->owns($actor, (object) $candidate, false)); $version = $this->positive($data['version'] ?? null) ? $data['version'] : null; $unavailable = ['availability' => $known ? 'restricted' : 'scope_unknown']; return ['version' => $version, 'restricted' => ! $visible, 'action' => $visible && in_array($data['action'] ?? null, ['record', 'confirm', 'start', 'complete', 'cancel', 'renew'], true) ? $data['action'] : null, 'actor_name' => $visible ? $this->person((int) $event->actor_id) : null, 'occurred_at' => $visible ? $event->occurred_at : null, 'reason' => $visible && is_string($data['reason'] ?? null) ? $data['reason'] : null, 'confirmation_party' => $visible && in_array($data['confirmation_party'] ?? null, ['proponent', 'candidate'], true) ? $data['confirmation_party'] : null, 'partnership_version' => $visible && $this->positive($data['partnership_version'] ?? null) ? $data['partnership_version'] : null, 'interest_version' => $visible && $this->positive($data['interest_version'] ?? null) ? $data['interest_version'] : null, 'previous' => $visible ? $this->snapshot($data['previous'] ?? null, $actor, (int) $context['room']->id) : $unavailable, 'after' => $visible ? $this->snapshot($data['applied'] ?? null, $actor, (int) $context['room']->id) : $unavailable]; } /** @return array */ private function snapshot(mixed $value, User $actor, int $id): array { if ($value === null) { return ['availability' => 'not_recorded']; } if (! is_array($value) || ($value['id'] ?? null) !== $id || ! $this->positive($value['version'] ?? null) || ! is_string($value['status'] ?? null)) { return ['availability' => 'scope_unknown']; } $view = ['availability' => 'available', 'version' => $value['version'], 'status' => $value['status'], 'instrument_availability' => 'not_recorded']; if (! array_key_exists('agreement', $value)) { return $view; } $instrument = $value['agreement']; if ($instrument === null) { return $view; } if (! is_array($instrument)) { return [...$view, 'instrument_availability' => 'malformed']; } $scope = $instrument['recorded_context'] ?? null; $proponent = is_array($scope) ? $this->scope($scope['proponent'] ?? null) : null; $candidate = is_array($scope) ? $this->scope($scope['candidate'] ?? null) : null; if ($proponent === null || $candidate === null || ! $this->positive($scope['partnership_version'] ?? null) || ! $this->positive($scope['interest_version'] ?? null)) { return [...$view, 'instrument_availability' => 'scope_unknown']; } $exchange = app(PartnershipExchange::class); if (! $exchange->owns($actor, (object) $proponent, false) && ! $exchange->owns($actor, (object) $candidate, false)) { return [...$view, 'instrument_availability' => 'restricted']; } $fields = $this->projection->fields($instrument, true); return [...$view, 'instrument_availability' => 'available', 'instrument' => $fields, 'instrument_partnership_version' => $scope['partnership_version'], 'instrument_interest_version' => $scope['interest_version']]; } /** @return array{id?:int,user_id:int,organization_id:string|null}|null */ private function scope(mixed $value): ?array { if (! is_array($value) || ! $this->positive($value['user_id'] ?? null) || ! array_key_exists('organization_id', $value) || ! ($value['organization_id'] === null || is_string($value['organization_id']) && Str::isUuid($value['organization_id']))) { return null; } $scope = ['user_id' => $value['user_id'], 'organization_id' => $value['organization_id']]; if ($this->positive($value['id'] ?? null)) { $scope['id'] = $value['id']; } return $scope; } /** @phpstan-assert-if-true positive-int $value */ private function positive(mixed $value): bool { return is_int($value) && $value > 0; } 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]; } }