<?php
namespace MedBrief\MSR\Security\Voter;
use MedBrief\MSR\Entity\Expert;
use MedBrief\MSR\Entity\MessageThreadParticipant;
use MedBrief\MSR\Entity\Project;
use MedBrief\MSR\Entity\User;
use MedBrief\MSR\Repository\AccountRepository;
use MedBrief\MSR\Repository\ExpertRepository;
use MedBrief\MSR\Repository\MatchLetterResponseRepository;
use MedBrief\MSR\Repository\MessageThreadParticipantRepository;
use MedBrief\MSR\Repository\ProjectRepository;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\User\UserInterface;
class MatchVoter extends Voter
{
public const VIEW_FIRM = 'MATCH_VIEW_FIRM';
public const VIEW_EXPERT = 'MATCH_VIEW_EXPERT';
public const VIEW_EXPERT_TERMS_ACCEPTED = 'MATCH_VIEW_EXPERT_TERMS_ACCEPTED';
public const VIEW_MESSAGES_NAV = 'MATCH_VIEW_MESSAGES_NAV';
public function __construct(
private readonly AuthorizationCheckerInterface $auth,
private readonly AccountRepository $accountRepository,
private readonly ExpertRepository $expertRepository,
private readonly MessageThreadParticipantRepository $messageThreadParticipantRepository,
private readonly ProjectRepository $projectRepository,
private readonly MatchLetterResponseRepository $matchLetterResponseRepository
) {
}
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
if ($attribute === self::VIEW_MESSAGES_NAV || $attribute === self::VIEW_EXPERT_TERMS_ACCEPTED) {
return $subject === null;
}
return \in_array($attribute, [self::VIEW_FIRM, self::VIEW_EXPERT], true)
&& $subject instanceof Project;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof UserInterface) {
return false;
}
/** @var Project $project */
$project = $subject;
return match ($attribute) {
self::VIEW_FIRM => $this->canViewAsFirm($user, $project),
self::VIEW_EXPERT => $this->canViewAsExpert($user, $project),
self::VIEW_EXPERT_TERMS_ACCEPTED => $this->canViewExpertTermsAccepted($user),
self::VIEW_MESSAGES_NAV => $this->canViewMessagesNavigation($user),
default => false,
};
}
/**
* Determines if the user can view the match tab.
* User roles allowed:
* - MB super admins (ROLE_SUPER_ADMIN)
* - MB admins (ROLE_ADMIN)
* - Client super admins (ROLE_ACCOUNT_{accountId}_SUPERADMINISTRATOR)
* - Client admins (ROLE_ACCOUNT_{accountId}_ADMINISTRATOR)
* - Matter/Project managers:
* - Role-based: ROLE_PROJECT_{projectId}_PROJECTMANAGER
* - Entity-based: $project->getManager() === $user
*
* @param UserInterface $user
* @param Project $project
*
* @return bool
*/
private function canViewAsFirm(UserInterface $user, Project $project): bool
{
// 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
if (!$project->getAccount()->getMatchOptIn()
|| $project->getMatterType() !== Project::MATTER_TYPE_CLINICAL_NEGLIGENCE
|| $project->getType() !== Project::TYPE_MATTER_FIRM
|| $project->getMatterRequest() === null
) {
return false;
}
// MB admins
if ($this->auth->isGranted('ROLE_SUPER_ADMIN') || $this->auth->isGranted('ROLE_ADMIN')) {
return true;
}
// Client-level admins
if ($this->auth->isGranted('ROLE_ACCOUNT_' . $project->getAccount()->getId() . '_SUPERADMINISTRATOR')) {
return true;
}
if ($this->auth->isGranted('ROLE_ACCOUNT_' . $project->getAccount()->getId() . '_ADMINISTRATOR')) {
return true;
}
// Matter/Project manager (role-based)
if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_PROJECTMANAGER')) {
return true;
}
// Matter/Project manager (entity relationship fallback)
if ($project->getManager() && $project->getManager() === $user) {
return true;
}
return false;
}
/**
* Determines if a user can view as an Expert.
*
* @param UserInterface $user
* @param Project $project
*
* @return bool
*/
private function canViewAsExpert(UserInterface $user, Project $project): bool
{
// Invited Experts
if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_EXPERT')) {
return true;
}
if ($this->auth->isGranted('ROLE_PROJECT_' . $project->getId() . '_EXPERTVIEWER')) {
return true;
}
// Uninvited Experts can only view if they are participants in the message thread for the project
$threadParticipant = $this->messageThreadParticipantRepository->findParticipantByProjectAndUser($project, $user);
if ($threadParticipant instanceof MessageThreadParticipant) {
return true;
}
return false;
}
/**
* Determines if a user can see the messages navigation tab
*
* @param UserInterface $user
*
* @return bool
*/
private function canViewMessagesNavigation(UserInterface $user): bool
{
// MB admins
if ($this->auth->isGranted('ROLE_SUPER_ADMIN') || $this->auth->isGranted('ROLE_ADMIN')) {
return true;
}
// If the user has the expert or expert viewer role, allow access
if ($user instanceof User && ($user->isExpertOnly() || $user->isExpertViewerOnly())) {
// Only experts with an expert profile
if ($this->expertRepository->findOneBy(['user' => $user]) instanceof Expert) {
return true;
}
}
// Extract account IDs and project IDs from user roles
$userRoles = $user->getRoles();
$accountIds = [];
$projectIds = [];
foreach ($userRoles as $role) {
if (preg_match('/^ROLE_ACCOUNT_(\d+)_/', $role, $matches)) {
$accountIds[] = (int) $matches[1];
}
if (preg_match('/^ROLE_PROJECT_(\d+)_/', $role, $matches)) {
$projectIds[] = (int) $matches[1];
}
}
// Remove duplicates
$accountIds = array_unique($accountIds);
$projectIds = array_unique($projectIds);
// Check if any accounts have match enabled
if (!empty($accountIds)) {
$accounts = $this->accountRepository->findBy(['id' => $accountIds]);
foreach ($accounts as $account) {
if ($account->getMatchOptIn()) {
return true;
}
}
}
// Check if any projects with accounts have match enabled
if (!empty($projectIds)) {
$projects = $this->projectRepository->findBy(['id' => $projectIds]);
foreach ($projects as $project) {
$account = $project->getAccount();
if ($account && $account->getMatchOptIn()) {
return true;
}
}
}
return false;
}
/**
* Determines if an expert user has accepted the expert terms.
*
* @param UserInterface $user
*
* @return bool
*/
private function canViewExpertTermsAccepted(UserInterface $user): bool
{
// If the user has the expert or expert viewer role, allow access
if ($user instanceof User && ($user->isExpertOnly() || $user->isExpertViewerOnly())) {
// Only experts with an expert profile
if ($this->expertRepository->findOneBy(['user' => $user]) instanceof Expert) {
// Check if the expert has accepted the terms
if ($this->matchLetterResponseRepository->hasExpertAcceptedTerms($user)) {
return true;
}
}
}
// Default deny
return false;
}
}