|null> */ private array $clientIds = []; /** @var array|null> */ private array $groupIds = []; public function __construct(private readonly StaffLibraryScope $scope) {} /** * Whether $viewer may be told that $subject exists, and what they are * called. * * A null subject is permitted: there is no identity to leak, and every * caller here is reading an optional relation. */ public function permits(?User $viewer, ?User $subject): bool { if ($subject === null) { return true; } if (! $subject->isClient()) { return true; } if ($viewer === null) { return false; } if ($viewer->is($subject)) { return true; } $ids = $this->identifiableClientIds($viewer); return $ids === null || in_array($subject->id, $ids, true); } /** * The same question about a client known only by id — used where a * caller has a foreign key rather than a loaded model. * * An id that belongs to nobody, or to a staff member, is permitted: * there is no client identity behind it to protect. */ public function permitsClientId(?User $viewer, ?int $id): bool { if ($id === null) { return true; } return $this->permits($viewer, User::query()->find($id)); } /** * Whether $viewer may be told a group exists. * * A group is a list of clients wearing one name, so naming one to * somebody who may reach none of its members says the same thing * naming a client would. The set is StaffLibraryScope's * assignableGroupIds — every group holding at least one of the * viewer's own clients. */ public function permitsGroupId(?User $viewer, ?int $id): bool { if ($id === null) { return true; } if ($viewer === null) { return false; } $ids = $this->identifiableGroupIds($viewer); return $ids === null || in_array($id, $ids, true); } /** * A client's name, or null when this viewer may not be told it. * * Null rather than a placeholder on purpose: every consumer of these * fields already renders "no uploader recorded" for a null, because a * deleted account leaves one behind. Inventing a "Hidden" string would * be a new thing for sixteen locales to translate and would itself * announce that there is somebody there to hide. */ public function nameOf(?User $viewer, ?User $subject): ?string { return $this->permits($viewer, $subject) ? $subject?->name : null; } /** * Drop the entries this viewer may not be told about from a list of * id/name pairs describing clients. * * @param list $pairs * @return list */ public function filterClientPairs(?User $viewer, array $pairs): array { if ($this->identifiableClientIds($viewer) === null) { return $pairs; } return array_values(array_filter( $pairs, fn (array $pair): bool => $this->permitsClientId($viewer, $pair['id']), )); } /** * @param list $pairs * @return list */ public function filterGroupPairs(?User $viewer, array $pairs): array { if ($this->identifiableGroupIds($viewer) === null) { return $pairs; } return array_values(array_filter( $pairs, fn (array $pair): bool => $this->permitsGroupId($viewer, $pair['id']), )); } /** * Both halves of a `shares` payload at once, since the two lists are * always filtered together. * * @param array{clients: list, groups: list} $shares * @return array{clients: list, groups: list} */ public function filterShares(?User $viewer, array $shares): array { return [ 'clients' => $this->filterClientPairs($viewer, $shares['clients']), 'groups' => $this->filterGroupPairs($viewer, $shares['groups']), ]; } /** * Whether this viewer is narrowed at all. Callers use it to skip * per-row work for the common unscoped case. */ public function isNarrowed(?User $viewer): bool { return $viewer === null || $this->identifiableClientIds($viewer) !== null; } /** * @return list|null */ private function identifiableClientIds(?User $viewer): ?array { if ($viewer === null) { return []; } // Deliberately the same set as "who may I share with". A client on // the roster is one this viewer already works with by name; a // client off it is one they have no business knowing exists. return $this->clientIds[$viewer->id] ??= $this->scope->assignableClientIds($viewer); } /** * @return list|null */ private function identifiableGroupIds(?User $viewer): ?array { if ($viewer === null) { return []; } return $this->groupIds[$viewer->id] ??= $this->scope->assignableGroupIds($viewer); } }