$payload * @return array */ public function context(stdClass $record, array $payload): array { $interest = null; $room = null; if (isset($payload['room_id'])) { $room = DB::table('partnership_rooms')->where('id', $payload['room_id'])->first(); if (! $room) { throw new LogicException('Notification room does not exist.'); } $interest = DB::table('partnership_interests')->where('id', $room->interest_id)->first(); } if (isset($payload['interest_id'])) { $selected = DB::table('partnership_interests')->where('id', $payload['interest_id'])->first(); if (! $selected || $interest && (int) $selected->id !== (int) $interest->id) { throw new LogicException('Notification candidacy is inconsistent.'); } $interest = $selected; } if ($interest && (int) $interest->partnership_id !== (int) $record->id) { throw new LogicException('Notification belongs to another partnership.'); } return ['version' => 1, 'partnership' => $this->scope($record), 'interest' => $interest ? $this->scope($interest) : null, 'room_id' => $room ? (int) $room->id : null]; } /** @return array{id:int,user_id:int,organization_id:string|null} */ private function scope(stdClass $record): array { return ['id' => (int) $record->id, 'user_id' => (int) $record->user_id, 'organization_id' => $record->organization_id]; } /** @return list */ public function forUser(User $actor): array { // Filter before the visible limit. An old, inaccessible notice never conceals a newer eligible page. return array_values($this->query($actor)->lazyByIdDesc(100, 'n.id', 'id')->map(fn ($notice) => $this->project($actor, $notice))->filter()->take(50)->all()); } public function markRead(User $actor, int $id): void { DB::transaction(function () use ($actor, $id): void { $notice = $this->query($actor)->where('n.id', $id)->lockForUpdate()->first(); abort_unless($notice && $this->project($actor, $notice) !== null, 404); if ($notice->read_at === null) { DB::table('partnership_notifications')->where('id', $id)->where('user_id', $actor->id)->whereNull('read_at')->update(['read_at' => now()]); } }); } private function query(User $actor): Builder { return DB::table('partnership_notifications as n')->join('partnership_events as e', 'e.id', '=', 'n.event_id')->where('n.user_id', $actor->id) ->select(['n.id', 'n.title', 'n.read_at', 'e.partnership_id', 'e.event_type', 'e.payload']); } /** @return array{id:int,title:string,href:string,read_at:string|null}|null */ private function project(User $actor, stdClass $notice): ?array { $envelope = json_decode($notice->payload, true, 512, JSON_THROW_ON_ERROR); $payload = is_array($envelope) && is_array($envelope['payload'] ?? null) ? $envelope['payload'] : []; $record = DB::table('portal_partnerships')->where('id', $notice->partnership_id)->first(); if (! $record) { return null; } $context = $payload['_notification_context'] ?? null; if (array_key_exists('_notification_context', $payload)) { if (! is_array($context) || ($context['version'] ?? null) !== 1 || ! $this->knownScope($context['partnership'] ?? null) || (int) $context['partnership']['id'] !== (int) $record->id) { return null; } if (($context['interest'] ?? null) !== null && ! $this->knownScope($context['interest'])) { return null; } } else { $context = $this->legacyContext($notice, $payload); if ($context === null) { return null; } } $interest = null; if (is_array($context['interest'] ?? null)) { $interest = DB::table('partnership_interests')->where('id', $context['interest']['id'])->where('partnership_id', $record->id)->first(); if (! $interest) { return null; } } $exchange = app(PartnershipExchange::class); $proponent = is_array($context['partnership'] ?? null) && $exchange->owns($actor, $record, false) && $exchange->owns($actor, (object) $context['partnership'], false); $candidate = $interest && $exchange->owns($actor, $interest, false) && $exchange->owns($actor, (object) $context['interest'], false); if (! $proponent && ! $candidate) { return null; } $href = '/parcerias/'.$record->id; if ($interest) { $href = '/parcerias/candidaturas/'.$interest->id.'/historico'; if (is_int($context['room_id'] ?? null) && DB::table('partnership_rooms')->where('id', $context['room_id'])->where('interest_id', $interest->id)->exists()) { $href = '/parcerias/salas/'.$context['room_id']; } } return ['id' => (int) $notice->id, 'title' => $notice->title, 'href' => $href, 'read_at' => $notice->read_at]; } private function knownScope(mixed $scope): bool { return is_array($scope) && is_int($scope['id'] ?? null) && $scope['id'] > 0 && is_int($scope['user_id'] ?? null) && $scope['user_id'] > 0 && array_key_exists('organization_id', $scope) && ($scope['organization_id'] === null || is_string($scope['organization_id']) && Str::isUuid($scope['organization_id'])); } /** @param array $payload * @return array|null */ private function legacyContext(stdClass $notice, array $payload): ?array { if (! in_array($notice->event_type, ['partnership_interest.submitted.v1', 'partnership_interest.changed.v1'], true) || ! is_int($payload['interest_id'] ?? null)) { return null; } $snapshot = $notice->event_type === 'partnership_interest.submitted.v1' ? ($payload['submitted'] ?? null) : ($payload['previous'] ?? null); if (! is_array($snapshot) || ! array_key_exists('organization_id', $snapshot)) { return null; } $interest = DB::table('partnership_interests')->where('id', $payload['interest_id'])->where('partnership_id', $notice->partnership_id)->first(); if (! $interest) { return null; } $scope = ['id' => (int) $interest->id, 'user_id' => (int) $interest->user_id, 'organization_id' => $snapshot['organization_id']]; return $this->knownScope($scope) ? ['partnership' => null, 'interest' => $scope, 'room_id' => null] : null; } }