feat(secretary): grant staff/discounts/sms/appointment_settings/clinic_doctors (phase B)

Extends the secretary permission system to five previously owner-only modules,
so a clinic/doctor can delegate each page to a secretary. All were unreachable
by secretaries before (role-based tenant resolution returned "unknown" → 403).

New permission resources (default-deny, three-place add: entity default,
SecretaryPermissions type, both MySecretariesPage + admin SecretariesPage):
staff, discounts, sms, appointment_settings (view/update only), clinic_doctors
(clinic-only — hidden from independent doctors via `clinicOnly` section filter).

Backend enforcement (SecretaryAccessChecker, three new reusable helpers):
- resolveOwnerEntity(): owner pair from active context — used by StaffController,
  DiscountController, SmsWalletController (now secretary-aware resolveEntity).
- canForDoctor(): per-doctor-scoped check (assigned doctor + toggle) — wired into
  AppointmentSettingsController::denyDoctorAccess.
- canForClinic(): clinic-scoped check — wired into ClinicController::detachDoctor,
  ClinicDoctorPermissionController (view/update), ClinicInvitationController
  (create/view/update/delete). clinic_doctors is clinic-context only.
Guards run ahead of any subscription gate; non-secretary roles pass unchanged.

Frontend:
- RoleRoute: staff, discounts, sms-wallet, appointment-settings (doctor+clinic
  variants), settings/clinic-doctors routes accept secretary + permission gate.
- Sidebar (secretary branch): five new items gated by can(); appointment_settings
  route follows active scope; clinic_doctors only in clinic scope.

Tests: SecretaryResourceEnforcementTest — denied-by-default + allowed-when-granted
for all five (18 total). Sidebar.test — B-resource gating + clinic_doctors scope
rule. docs/api/secretary.md resource list, enforcement map, JSON example updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-23 17:29:51 +03:30
co-authored by Claude Opus 4.8
parent b9189700b3
commit e8bf2ce9b1
17 changed files with 486 additions and 22 deletions
+3 -1
View File
@@ -47,6 +47,7 @@ class ClinicController extends BaseController
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
private readonly FileValidatorService $fileValidator,
private readonly \App\Representation\Service\DomainContextResolver $domainResolver,
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
private readonly string $projectDir,
) {}
@@ -383,7 +384,8 @@ class ClinicController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
if (!$this->canManageClinic($clinic, $user)) {
if (!$this->canManageClinic($clinic, $user)
&& !$this->secretaryAccess->canForClinic($user, $clinic, 'clinic_doctors', 'delete')) {
return $this->error(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی مجاز نیست', 403);
}
@@ -27,13 +27,14 @@ class ClinicDoctorPermissionController extends BaseController
private readonly DoctorRepository $doctorRepo,
private readonly ClinicDoctorPermissionRepository $permRepo,
private readonly EntityManagerInterface $em,
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
) {}
#[Route('/api/v1/admin/clinic/{clinicUuid}/doctor-permissions', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function listPermissions(string $clinicUuid, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->resolveClinic($clinicUuid, $user);
$clinic = $this->resolveClinic($clinicUuid, $user, 'view');
$data = array_map(
fn(ClinicDoctorPermission $p) => $p->toArray(),
@@ -47,7 +48,7 @@ class ClinicDoctorPermissionController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function showPermissions(string $clinicUuid, string $doctorUuid, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->resolveClinic($clinicUuid, $user);
$clinic = $this->resolveClinic($clinicUuid, $user, 'view');
$doctor = $this->resolveMember($clinic, $doctorUuid);
return $this->success($this->permRepo->getOrCreate($clinic, $doctor)->toArray());
@@ -57,7 +58,7 @@ class ClinicDoctorPermissionController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updatePermissions(string $clinicUuid, string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
$clinic = $this->resolveClinic($clinicUuid, $user);
$clinic = $this->resolveClinic($clinicUuid, $user, 'update');
$doctor = $this->resolveMember($clinic, $doctorUuid);
$perm = $this->permRepo->getOrCreate($clinic, $doctor);
@@ -79,7 +80,7 @@ class ClinicDoctorPermissionController extends BaseController
return $this->success($perm->toArray());
}
private function resolveClinic(string $clinicUuid, User $user): Clinic
private function resolveClinic(string $clinicUuid, User $user, string $action): Clinic
{
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
if ($clinic === null) {
@@ -87,7 +88,9 @@ class ClinicDoctorPermissionController extends BaseController
}
$isOwner = $clinic->getUser()->getId() === $user->getId();
if (!$user->hasRole('ROLE_ADMIN') && !$isOwner) {
// منشیِ همان کلینیک با توگلِ clinic_doctors می‌تواند مدیریت پزشکان را انجام دهد.
$isSecretary = $this->secretaryAccess->canForClinic($user, $clinic, 'clinic_doctors', $action);
if (!$user->hasRole('ROLE_ADMIN') && !$isOwner && !$isSecretary) {
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403);
}