src/Security/Voter/MatchVoter.php line 20

Open in your IDE?
  1. <?php
  2. namespace MedBrief\MSR\Security\Voter;
  3. use MedBrief\MSR\Entity\Expert;
  4. use MedBrief\MSR\Entity\MessageThreadParticipant;
  5. use MedBrief\MSR\Entity\Project;
  6. use MedBrief\MSR\Entity\User;
  7. use MedBrief\MSR\Repository\AccountRepository;
  8. use MedBrief\MSR\Repository\ExpertRepository;
  9. use MedBrief\MSR\Repository\MatchLetterResponseRepository;
  10. use MedBrief\MSR\Repository\MessageThreadParticipantRepository;
  11. use MedBrief\MSR\Repository\ProjectRepository;
  12. use Override;
  13. use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
  14. use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
  15. use Symfony\Component\Security\Core\Authorization\Voter\Voter;
  16. use Symfony\Component\Security\Core\User\UserInterface;
  17. class MatchVoter extends Voter
  18. {
  19. public const VIEW_FIRM = 'MATCH_VIEW_FIRM';
  20. public const VIEW_EXPERT = 'MATCH_VIEW_EXPERT';
  21. public const VIEW_EXPERT_TERMS_ACCEPTED = 'MATCH_VIEW_EXPERT_TERMS_ACCEPTED';
  22. public const VIEW_MESSAGES_NAV = 'MATCH_VIEW_MESSAGES_NAV';
  23. public function __construct(
  24. private readonly AuthorizationCheckerInterface $auth,
  25. private readonly AccountRepository $accountRepository,
  26. private readonly ExpertRepository $expertRepository,
  27. private readonly MessageThreadParticipantRepository $messageThreadParticipantRepository,
  28. private readonly ProjectRepository $projectRepository,
  29. private readonly MatchLetterResponseRepository $matchLetterResponseRepository
  30. ) {
  31. }
  32. #[Override]
  33. protected function supports(string $attribute, mixed $subject): bool
  34. {
  35. if ($attribute === self::VIEW_MESSAGES_NAV || $attribute === self::VIEW_EXPERT_TERMS_ACCEPTED) {
  36. return $subject === null;
  37. }
  38. return \in_array($attribute, [self::VIEW_FIRM, self::VIEW_EXPERT], true)
  39. && $subject instanceof Project;
  40. }
  41. #[Override]
  42. protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
  43. {
  44. $user = $token->getUser();
  45. if (!$user instanceof UserInterface) {
  46. return false;
  47. }
  48. /** @var Project $project */
  49. $project = $subject;
  50. return match ($attribute) {
  51. self::VIEW_FIRM => $this->canViewAsFirm($user, $project),
  52. self::VIEW_EXPERT => $this->canViewAsExpert($user, $project),
  53. self::VIEW_EXPERT_TERMS_ACCEPTED => $this->canViewExpertTermsAccepted($user),
  54. self::VIEW_MESSAGES_NAV => $this->canViewMessagesNavigation($user),
  55. default => false,
  56. };
  57. }
  58. /**
  59. * Determines if the user can view the match tab.
  60. * User roles allowed:
  61. * - MB super admins (ROLE_SUPER_ADMIN)
  62. * - MB admins (ROLE_ADMIN)
  63. * - Client super admins (ROLE_ACCOUNT_{accountId}_SUPERADMINISTRATOR)
  64. * - Client admins (ROLE_ACCOUNT_{accountId}_ADMINISTRATOR)
  65. * - Matter/Project managers:
  66. * - Role-based: ROLE_PROJECT_{projectId}_PROJECTMANAGER
  67. * - Entity-based: $project->getManager() === $user
  68. *
  69. * @param UserInterface $user
  70. * @param Project $project
  71. *
  72. * @return bool
  73. */
  74. private function canViewAsFirm(UserInterface $user, Project $project): bool
  75. {
  76. // Check if client has opted in to match and if the matter is of type Clinical Negligence and is a client matter, if classic matter, don't allow viewing of match
  77. if (!$project->getAccount()->getMatchOptIn()
  78. || $project->getMatterType() !== Project::MATTER_TYPE_CLINICAL_NEGLIGENCE
  79. || $project->getType() !== Project::TYPE_MATTER_FIRM
  80. || $project->getMatterRequest() === null
  81. ) {
  82. return false;
  83. }
  84. // MB admins
  85. if ($this->auth->isGranted('ROLE_SUPER_ADMIN') || $this->auth->isGranted('ROLE_ADMIN')) {
  86. return true;
  87. }
  88. // Client-level admins
  89. if ($this->auth->isGranted('ROLE_ACCOUNT_' . $project->getAccount()->getId() . '_SUPERADMINISTRATOR')) {
  90. return true;
  91. }
  92. if ($this->auth->isGranted('ROLE_ACCOUNT_' . $project->getAccount()->getId() . '_ADMINISTRATOR')) {
  93. return true;
  94. }
  95. // Matter/Project manager (role-based)
  96. if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_PROJECTMANAGER')) {
  97. return true;
  98. }
  99. // Matter/Project manager (entity relationship fallback)
  100. if ($project->getManager() && $project->getManager() === $user) {
  101. return true;
  102. }
  103. return false;
  104. }
  105. /**
  106. * Determines if a user can view as an Expert.
  107. *
  108. * @param UserInterface $user
  109. * @param Project $project
  110. *
  111. * @return bool
  112. */
  113. private function canViewAsExpert(UserInterface $user, Project $project): bool
  114. {
  115. // Invited Experts
  116. if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_EXPERT')) {
  117. return true;
  118. }
  119. if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_EXPERTVIEWER')) {
  120. return true;
  121. }
  122. // Uninvited Experts can only view if they are participants in the message thread for the project
  123. $threadParticipant = $this->messageThreadParticipantRepository->findParticipantByProjectAndUser($project, $user);
  124. if ($threadParticipant instanceof MessageThreadParticipant) {
  125. return true;
  126. }
  127. return false;
  128. }
  129. /**
  130. * Determines if a user can see the messages navigation tab
  131. *
  132. * @param UserInterface $user
  133. *
  134. * @return bool
  135. */
  136. private function canViewMessagesNavigation(UserInterface $user): bool
  137. {
  138. // MB admins
  139. if ($this->auth->isGranted('ROLE_SUPER_ADMIN') || $this->auth->isGranted('ROLE_ADMIN')) {
  140. return true;
  141. }
  142. // If the user has the expert or expert viewer role, allow access
  143. if ($user instanceof User && ($user->isExpertOnly() || $user->isExpertViewerOnly())) {
  144. // Only experts with an expert profile
  145. if ($this->expertRepository->findOneBy(['user' => $user]) instanceof Expert) {
  146. return true;
  147. }
  148. }
  149. // Extract account IDs and project IDs from user roles
  150. $userRoles = $user->getRoles();
  151. $accountIds = [];
  152. $projectIds = [];
  153. foreach ($userRoles as $role) {
  154. if (preg_match('/^ROLE_ACCOUNT_(\d+)_/', $role, $matches)) {
  155. $accountIds[] = (int) $matches[1];
  156. }
  157. if (preg_match('/^ROLE_PROJECT_(\d+)_/', $role, $matches)) {
  158. $projectIds[] = (int) $matches[1];
  159. }
  160. }
  161. // Remove duplicates
  162. $accountIds = array_unique($accountIds);
  163. $projectIds = array_unique($projectIds);
  164. // Check if any accounts have match enabled
  165. if (!empty($accountIds)) {
  166. $accounts = $this->accountRepository->findBy(['id' => $accountIds]);
  167. foreach ($accounts as $account) {
  168. if ($account->getMatchOptIn()) {
  169. return true;
  170. }
  171. }
  172. }
  173. // Check if any projects with accounts have match enabled
  174. if (!empty($projectIds)) {
  175. $projects = $this->projectRepository->findBy(['id' => $projectIds]);
  176. foreach ($projects as $project) {
  177. $account = $project->getAccount();
  178. if ($account && $account->getMatchOptIn()) {
  179. return true;
  180. }
  181. }
  182. }
  183. return false;
  184. }
  185. /**
  186. * Determines if an expert user has accepted the expert terms.
  187. *
  188. * @param UserInterface $user
  189. *
  190. * @return bool
  191. */
  192. private function canViewExpertTermsAccepted(UserInterface $user): bool
  193. {
  194. // If the user has the expert or expert viewer role, allow access
  195. if ($user instanceof User && ($user->isExpertOnly() || $user->isExpertViewerOnly())) {
  196. // Only experts with an expert profile
  197. if ($this->expertRepository->findOneBy(['user' => $user]) instanceof Expert) {
  198. // Check if the expert has accepted the terms
  199. if ($this->matchLetterResponseRepository->hasExpertAcceptedTerms($user)) {
  200. return true;
  201. }
  202. }
  203. }
  204. // Default deny
  205. return false;
  206. }
  207. }