feat: add RichTextEditor component for rich text editing in articles

feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies

test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
This commit is contained in:
hamed
2026-08-08 11:40:17 +03:30
parent 934405c42d
commit 47323daa27
36 changed files with 3461 additions and 12686 deletions
@@ -38,6 +38,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ResourceBlockController extends BaseController
{
use \App\Shared\Controller\PermissionGateTrait;
public function __construct(
private readonly ClinicResourceRepository $resources,
private readonly ResourceOccupancyRepository $occupancy,
@@ -63,6 +65,11 @@ class ResourceBlockController extends BaseController
#[Route('/api/v1/resource/{uuid}/blocks', name: 'resource_block_create', methods: ['POST'])]
public function create(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->denySecretaryWithout($user, 'resources', 'create');
$resource = $this->requireResource($user, $uuid);
$data = json_decode($request->getContent(), true);
@@ -105,6 +112,11 @@ class ResourceBlockController extends BaseController
#[Route('/api/v1/resource-block/{uuid}', name: 'resource_block_delete', methods: ['DELETE'])]
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->denySecretaryWithout($user, 'resources', 'delete');
$block = $this->occupancy->findOneBy(['uuid' => $uuid]);
[$entityType, $entityId] = $this->branches->pair($user);
@@ -29,6 +29,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Appointments')]
class AppointmentController extends BaseController
{
use \App\Shared\Controller\PermissionGateTrait;
public function __construct(
private readonly AppointmentRepository $appointmentRepo,
private readonly DoctorRepository $doctorRepo,
@@ -1042,6 +1044,11 @@ class AppointmentController extends BaseController
#[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])]
public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->denySecretaryWithout($user, 'appointments', 'update_status');
$appointment = $this->appointmentRepo->findByUuid($uuid);
if ($appointment === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
@@ -1131,6 +1138,11 @@ class AppointmentController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function confirm(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->denySecretaryWithout($user, 'appointments', 'update_status');
$appointment = $this->appointmentRepo->findByUuid($uuid);
if ($appointment === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
@@ -1214,6 +1226,11 @@ class AppointmentController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->denySecretaryWithout($user, 'appointments', 'update_status');
$appointment = $this->appointmentRepo->findByUuid($uuid);
if ($appointment === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
@@ -1396,6 +1413,11 @@ class AppointmentController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function serviceReschedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->denySecretaryWithout($user, 'appointments', 'update_status');
$appointment = $this->appointmentRepo->findByUuid($uuid);
if ($appointment === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
@@ -187,6 +187,12 @@ class AppointmentSettingsController extends BaseController
#[Route('/api/v1/appointment-settings/weekly-schedule', methods: ['POST'])]
public function createSchedule(Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
// همان رکورد نیاز دارد و بالا نمی‌رود؛ ولی سهمِ منشی از آن همیشه همین
// توگل است، پس این خط هیچ مسیرِ مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳
// تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? '');
@@ -243,6 +249,12 @@ class AppointmentSettingsController extends BaseController
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['PATCH'])]
public function updateSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
// همان رکورد نیاز دارد و بالا نمی‌رود؛ ولی سهمِ منشی از آن همیشه همین
// توگل است، پس این خط هیچ مسیرِ مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳
// تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
$data = json_decode($request->getContent(), true) ?? [];
// uuid may be doctor uuid or schedule uuid
@@ -325,6 +337,12 @@ class AppointmentSettingsController extends BaseController
#[Route('/api/v1/booking-setting/{uuid}', methods: ['DELETE'])]
public function deleteSchedule(string $uuid, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
// همان رکورد نیاز دارد و بالا نمی‌رود؛ ولی سهمِ منشی از آن همیشه همین
// توگل است، پس این خط هیچ مسیرِ مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳
// تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
$schedule = $this->scheduleRepo->findByUuid($uuid);
if ($schedule === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
@@ -366,6 +384,12 @@ class AppointmentSettingsController extends BaseController
#[Route('/api/v1/appointment-settings/date-override', methods: ['POST'])]
public function createOverride(Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
// همان رکورد نیاز دارد و بالا نمی‌رود؛ ولی سهمِ منشی از آن همیشه همین
// توگل است، پس این خط هیچ مسیرِ مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳
// تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$dateStr = trim($data['date'] ?? '');
@@ -399,6 +423,12 @@ class AppointmentSettingsController extends BaseController
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['PATCH'])]
public function updateOverride(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
// همان رکورد نیاز دارد و بالا نمی‌رود؛ ولی سهمِ منشی از آن همیشه همین
// توگل است، پس این خط هیچ مسیرِ مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳
// تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
$override = $this->overrideRepo->findByUuid($uuid);
if ($override === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
@@ -425,6 +455,12 @@ class AppointmentSettingsController extends BaseController
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['DELETE'])]
public function deleteOverride(string $uuid, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
// همان رکورد نیاز دارد و بالا نمی‌رود؛ ولی سهمِ منشی از آن همیشه همین
// توگل است، پس این خط هیچ مسیرِ مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳
// تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
$override = $this->overrideRepo->findByUuid($uuid);
if ($override === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
@@ -484,6 +520,12 @@ class AppointmentSettingsController extends BaseController
#[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['DELETE'])]
public function deleteHoliday(string $uuid, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
// همان رکورد نیاز دارد و بالا نمی‌رود؛ ولی سهمِ منشی از آن همیشه همین
// توگل است، پس این خط هیچ مسیرِ مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳
// تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
$holiday = $this->holidayRepo->findByUuid($uuid);
if ($holiday === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
@@ -501,6 +543,12 @@ class AppointmentSettingsController extends BaseController
#[Route('/api/v1/appointment-settings/holidays', methods: ['POST'])]
public function createHoliday(Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
// همان رکورد نیاز دارد و بالا نمی‌رود؛ ولی سهمِ منشی از آن همیشه همین
// توگل است، پس این خط هیچ مسیرِ مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳
// تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
$data = json_decode($request->getContent(), true) ?? [];
$doctorUuid = trim($data['doctor_uuid'] ?? '');
$startStr = trim($data['start_date'] ?? '');
@@ -541,6 +589,12 @@ class AppointmentSettingsController extends BaseController
#[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['PATCH'])]
public function updateHoliday(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
// همان رکورد نیاز دارد و بالا نمی‌رود؛ ولی سهمِ منشی از آن همیشه همین
// توگل است، پس این خط هیچ مسیرِ مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳
// تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
$holiday = $this->holidayRepo->findByUuid($uuid);
if ($holiday === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
@@ -62,6 +62,10 @@ class MyAppointmentsController extends BaseController
private readonly \App\Resource\Service\ResourceBookingSlotService $resourceSlots,
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
// اتصال نوبت به جلسهٔ درمان جفتِ [entityType, entityId] می‌خواهد. تا پیش از
// ۲۰۲۶-۰۸-۰۸ همین‌جا صدا زده می‌شد ولی هرگز تزریق نشده بود، پس هر درخواستِ
// دارای `treatment_session_uuid` روی «Undefined property» ۵۰۰ می‌گرفت.
private readonly \App\Doctor\Service\AddressResolver $branches,
) {}
/**
+1 -1
View File
@@ -760,7 +760,7 @@ class AuthController extends BaseController
if ($rel->getOwnerType() === \App\Secretary\Entity\DoctorSecretary::OWNER_CLINIC && $rel->getClinic() !== null) {
// scope کلینیک — یک context به ازای هر کلینیک (نه هر دکتر)
$clinicUuid = $rel->getClinic()->getUuid();
$alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinicUuid && ($c['role'] ?? '') === 'secretary');
$alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinicUuid && $c['role'] === 'secretary');
if (empty($alreadyAdded)) {
$contexts[] = [
'type' => 'clinic',
+8 -3
View File
@@ -225,15 +225,20 @@ class BillingController extends BaseController
/**
* فیلترهای مشترک لیست پرداخت‌ها و خلاصه‌ی آن؛ یک منبع تا دو نما واگرا نشوند.
*
* @return array{national_code:?string,status:?string,from:?string,to:?string}
* @return array{national_code:?string,status:?string,from:?int,to:?int}
*/
private function paymentFilters(Request $request): array
{
// `from`/`to` مهرِ زمانی‌اند و قرارداد سرویس `int` می‌خواهد. تبدیل همین‌جا
// انجام می‌شود، در مرزِ ورودی، نه در repository — وگرنه هر مصرف‌کنندهٔ تازه
// باید همان cast را تکرار کند.
$timestamp = static fn (?string $raw): ?int => ($raw ?: null) === null ? null : (int) $raw;
return [
'national_code' => $request->query->get('national_code') ?: null,
'status' => $request->query->get('status') ?: null,
'from' => $request->query->get('from') ?: null,
'to' => $request->query->get('to') ?: null,
'from' => $timestamp($request->query->get('from')),
'to' => $timestamp($request->query->get('to')),
];
}
@@ -0,0 +1,188 @@
<?php
namespace App\Blog\Command;
use App\Blog\Service\BlogBodySanitizer;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* پاک‌سازی بدنهٔ مقاله‌های موجود با همان سیاستی که مسیر ذخیره اعمال می‌کند.
*
* یافتهٔ ۵ آدیت ۲۰۲۶-۰۸-۰۷ دفاع را در **لحظهٔ ذخیره** گذاشت، پس مقاله‌هایی که پیش
* از آن ذخیره شده‌اند هنوز HTML خام دارند و `BlogReviewPage` خامشان را رندر می‌کند.
* این دستور همان بدهی را تسویه می‌کند.
*
* دستور است نه migration: سیاستِ `html_sanitizer.yaml` ممکن است دوباره سفت شود و
* آن‌وقت باید همین گذر دوباره اجرا شود. migration یک‌بارمصرف است.
*
* بدنه‌ای که پس از پاک‌سازی کاملاً خالی می‌شود دست‌نخورده می‌ماند و فقط گزارش
* می‌شود: مقالهٔ منتشرشده را نباید بی‌صدا تهی کرد — تصمیمش با آدم است.
*/
#[AsCommand(
name: 'app:blog:sanitize-bodies',
description: 'Re-runs the blog body sanitizer over rows saved before it existed',
)]
class SanitizeBlogBodiesCommand extends Command
{
public function __construct(
private readonly Connection $connection,
private readonly BlogBodySanitizer $sanitizer,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'فقط گزارش بده، چیزی ننویس');
$this->addOption('show', null, InputOption::VALUE_REQUIRED, 'قبل/بعدِ یک مقاله را چاپ کن (id)');
}
/**
* جنسِ تغییرِ یک بدنه: `stripped` یا `hardened`.
*
* تفکیک لازم است چون پاک‌سازی سه کارِ متفاوت می‌کند و فقط یکی‌شان امنیتی است:
*
* - decode شدن entity (`&nbsp;` → U+00A0) — بی‌اثر.
* - افزودن `rel="noopener noreferrer"` به `<a>` — سخت‌سازی، سیاستِ
* `html_sanitizer.yaml`. جلوی reverse tabnabbing را می‌گیرد.
* - **حذفِ** تگ یا attribute — تنها حالتی که یعنی آن مقاله markupِ غیرمجاز دارد.
*
* بدونِ این تفکیک، عددِ «۴۲۶ مقاله تغییر می‌کند» گمراه‌کننده بود و یک `UPDATE`
* انبوه را به‌جای یک بررسی هدفمند توجیه می‌کرد.
*/
private static function classify(string $before, string $after): string
{
// `<br>` → `<br />` فقط سریال‌سازیِ خروجی است. بدون یکسان‌سازی، هر مقالهٔ
// دارای خط‌شکن به‌غلط «markup غیرمجاز» گزارش می‌شد.
$tags = static function (string $html): array {
preg_match_all('/<[^>]+>/', $html, $m);
return array_map(
static fn (string $t): string => preg_replace('/\s*\/>$/', '>', $t) ?? $t,
$m[0],
);
};
// سیاست، `rel` را روی هر `<a>` **تحمیل** می‌کند. پس هم نبودنش و هم مقدارِ
// ضعیف‌ترِ قبلی (`rel="noopener"`) با مقدار کامل جایگزین می‌شود. هر دو سمت
// نرمال می‌شوند تا این سخت‌سازی به‌غلط «حذف» شمرده نشود.
$withoutRel = static fn (string $tag): string => preg_replace(
'/\s+rel="[^"]*"/',
'',
$tag,
) ?? $tag;
$beforeTags = array_map($withoutRel, $tags($before));
$afterTags = array_map($withoutRel, $tags($after));
if ($beforeTags !== $afterTags) {
return 'stripped';
}
$text = static function (string $html): string {
$decoded = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
return preg_replace('/\s+/u', ' ', str_replace("\u{a0}", ' ', $decoded)) ?? '';
};
return $text($before) === $text($after) ? 'hardened' : 'stripped';
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = (bool) $input->getOption('dry-run');
if (($showId = $input->getOption('show')) !== null) {
$before = (string) $this->connection->fetchOne('SELECT body FROM blogs WHERE id = ?', [(int) $showId]);
file_put_contents(sys_get_temp_dir() . '/blog-before.html', $before);
file_put_contents(sys_get_temp_dir() . '/blog-after.html', $this->sanitizer->clean($before));
$io->success(sys_get_temp_dir() . '/blog-{before,after}.html نوشته شد');
return Command::SUCCESS;
}
$rows = $this->connection->fetchAllAssociative('SELECT id, title, body FROM blogs');
$changed = [];
$emptied = [];
$hardened = 0;
$stripped = [];
foreach ($rows as $row) {
$before = (string) $row['body'];
$after = $this->sanitizer->clean($before);
if ($after === $before) {
continue;
}
if (trim(strip_tags($after)) === '') {
$emptied[] = $row;
continue;
}
if (self::classify($before, $after) === 'hardened') {
$hardened++;
} else {
$stripped[] = sprintf('#%d — %s', $row['id'], $row['title']);
}
$changed[] = ['id' => (int) $row['id'], 'title' => (string) $row['title'], 'body' => $after];
}
$io->section(sprintf('%d مقاله بررسی شد', count($rows)));
$io->definitionList(
['بدون تغییر' => count($rows) - count($changed) - count($emptied)],
['سخت‌سازی (افزودن rel / decode شدن entity)' => $hardened],
['حذفِ تگ یا attribute غیرمجاز' => count($stripped)],
['خالی می‌شد و دست‌نخورده ماند' => count($emptied)],
);
if ($stripped !== []) {
$io->warning('این مقاله‌ها markupِ غیرمجاز دارند:');
$io->listing(array_slice($stripped, 0, 30));
}
if ($emptied !== []) {
$io->warning(sprintf(
'%d مقاله پس از پاک‌سازی خالی می‌شد و دست‌نخورده ماند. دستی بررسی کن:',
count($emptied),
));
$io->listing(array_map(
static fn (array $r): string => sprintf('#%d — %s', $r['id'], $r['title']),
$emptied,
));
}
if ($changed === []) {
$io->success('هیچ بدنه‌ای تغییر نکرد؛ همه از قبل با سیاست فعلی هم‌خوان‌اند.');
return Command::SUCCESS;
}
if ($dryRun) {
$io->note(sprintf('dry-run: %d بدنه تغییر می‌کرد. چیزی نوشته نشد.', count($changed)));
return Command::SUCCESS;
}
$this->connection->transactional(function (Connection $conn) use ($changed): void {
foreach ($changed as $row) {
$conn->executeStatement(
'UPDATE blogs SET body = :body WHERE id = :id',
['body' => $row['body'], 'id' => $row['id']],
);
}
});
$io->success(sprintf('%d بدنه پاک‌سازی و ذخیره شد.', count($changed)));
return Command::SUCCESS;
}
}
@@ -224,6 +224,11 @@ class ClinicController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
$clinic = $this->clinicRepo->findByUuid($uuid);
if ($clinic === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
@@ -385,6 +390,11 @@ class ClinicController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function detachDoctor(string $clinicUuid, string $doctorUuid, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'delete');
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
if ($clinic === null) {
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
@@ -58,6 +58,11 @@ class ClinicDoctorPermissionController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updatePermissions(string $clinicUuid, string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'update');
$clinic = $this->resolveClinic($clinicUuid, $user, 'update');
$doctor = $this->resolveMember($clinic, $doctorUuid);
$perm = $this->permRepo->getOrCreate($clinic, $doctor);
@@ -35,6 +35,11 @@ class ClinicInvitationController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function inviteDoctor(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'create');
$clinic = $this->clinicRepo->findByUuid($uuid);
if (!$clinic) {
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
@@ -92,6 +97,11 @@ class ClinicInvitationController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function resendInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'create');
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
@@ -107,6 +117,11 @@ class ClinicInvitationController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function changeInvitationStatus(string $invUuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'update');
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
@@ -126,6 +141,11 @@ class ClinicInvitationController extends BaseController
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deleteInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
{
// پیش‌چکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
// نمی‌رود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
// مجازی نمی‌بندد و فقط ۴۰۴ را به ۴۰۳ تبدیل می‌کند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'delete');
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
if (!$inv) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
@@ -10,7 +10,6 @@ use App\Insurance\Entity\TenantServiceCoverage;
use App\Insurance\Enum\ServiceCategory;
use App\Clinic\Security\ClinicDoctorAccessChecker;
use App\Secretary\Security\SecretaryAccessChecker;
use Doctrine\ORM\EntityManagerInterface;
use App\ClinicService\Repository\CatalogCategoryRepository;
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
use App\ClinicService\Repository\ServiceItemRepository;
@@ -46,7 +45,6 @@ class ClinicServiceController extends BaseController
private readonly InventoryItemRepository $inventoryItemRepo,
private readonly ServiceItemAuditService $auditService,
private readonly ServiceItemAuditLogRepository $auditLogRepo,
private readonly EntityManagerInterface $em,
private readonly EntityContextResolver $contextResolver,
private readonly RequestStack $requestStack,
private readonly SecretaryAccessChecker $secretaryAccess,
+8
View File
@@ -147,6 +147,10 @@ class ServiceItem
public function getConsumables(): Collection
{
// Doctrine بدون constructor هیدریت می‌کند؛ از property تایپ‌شده محافظت کن.
// phpstan فقط constructor را می‌بیند و می‌گوید این property همیشه مقدار
// دارد. Doctrine اما بدون constructor هیدریت می‌کند، پس روی نمونهٔ
// نیمه‌ساخته می‌تواند initialize نشده باشد. گارد عمدی است.
/** @phpstan-ignore-next-line */
return $this->consumables ??= new ArrayCollection();
}
@@ -209,6 +213,10 @@ class ServiceItem
public function getStaffMembers(): Collection
{
// Doctrine hydrates without the constructor; guard the typed property.
// phpstan فقط constructor را می‌بیند و می‌گوید این property همیشه مقدار
// دارد. Doctrine اما بدون constructor هیدریت می‌کند، پس روی نمونهٔ
// نیمه‌ساخته می‌تواند initialize نشده باشد. گارد عمدی است.
/** @phpstan-ignore-next-line */
return $this->staffMembers ??= new ArrayCollection();
}
+2 -3
View File
@@ -149,7 +149,7 @@ class DoctorClaimService
private function finalize(Doctor $doctor, User $target, DoctorClaimRequest $claim, ?string $nationalCode): void
{
$surrogate = $this->em->wrapInTransaction(function () use ($doctor, $target, $claim, $nationalCode): ?User {
$surrogate = $this->em->wrapInTransaction(function () use ($doctor, $target, $claim, $nationalCode): User {
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
if ($locked->getOwnerStatus() !== 'pending_transfer') {
@@ -170,8 +170,7 @@ class DoctorClaimService
});
// حذف امن جانشین — پس از flush انتقال، تا شمارش پزشکانِ متصل قطعی باشد
if ($surrogate !== null
&& $surrogate->getId() !== $target->getId()
if ($surrogate->getId() !== $target->getId()
&& $surrogate->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)
&& $this->em->getRepository(Doctor::class)->count(['user' => $surrogate]) === 0) {
$this->em->remove($surrogate);
@@ -6,7 +6,6 @@ use App\Inventory\Entity\InventoryItem;
use App\Inventory\Entity\InventoryPackage;
use App\Inventory\Entity\InventoryPackageItem;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
/**
* Inventory domain logic: derived aggregates (stat counters, package totals and
@@ -17,7 +16,6 @@ class InventoryService
{
public function __construct(
private readonly InventoryItemRepository $itemRepo,
private readonly InventoryPackageRepository $packageRepo,
) {}
/**
-6
View File
@@ -4,17 +4,14 @@ namespace App\Patient\Service;
use App\Appointment\Entity\Appointment;
use App\Appointment\Service\AppointmentInsuranceService;
use App\Auth\Repository\UserRepository;
use App\Billing\Service\BillingCalculator;
use App\Billing\ValueObject\Money;
use App\ClinicService\Repository\ServiceItemRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Insurance\Enum\ServiceCategory;
use App\Insurance\Repository\EntityInsurancePricingRepository;
use App\Insurance\Service\TenantInsuranceService;
use App\Inventory\Repository\InventoryItemRepository;
use App\Inventory\Repository\InventoryPackageRepository;
use App\Doctor\Repository\DoctorAddressRepository;
use App\Auth\Entity\User;
use App\Patient\Entity\PatientRecord;
use App\Patient\Entity\PatientSession;
@@ -46,10 +43,7 @@ class PatientService
private readonly InventoryItemRepository $inventoryItemRepo,
private readonly InventoryPackageRepository $inventoryPackageRepo,
private readonly ClinicStaffRepository $staffRepo,
private readonly UserRepository $userRepo,
private readonly SubscriptionService $subscriptionService,
private readonly DoctorAddressRepository $addressRepo,
private readonly ClinicRepository $clinicRepo,
private readonly TenantInsuranceService $tenantInsuranceService,
private readonly AppointmentInsuranceService $appointmentInsurance,
private readonly BillingCalculator $billingCalculator,
@@ -73,7 +73,7 @@ class RecordNumberGenerator
if ($token === 'MM') return str_pad((string) $jm, 2, '0', STR_PAD_LEFT);
// {SEQ} یا {SEQ:n} — پدینگ سقف نیست: شمارندهٔ بلندتر از n بریده نمی‌شود.
$width = isset($m[2]) && $m[2] !== '' ? (int) $m[2] : 1;
$width = isset($m[2]) ? (int) $m[2] : 1;
return str_pad((string) $counter, $width, '0', STR_PAD_LEFT);
},
@@ -315,7 +315,7 @@ class ResourceController extends BaseController
private function dayStart(?string $date): ?int
{
if ($date === null || $date === '') {
return strtotime('today midnight') ?: null;
return strtotime('today midnight');
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
+4 -2
View File
@@ -12,6 +12,7 @@ use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Sms\Entity\SmsLog;
use App\Sms\Service\SmsService;
use App\Subscription\Service\SubscriptionService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
@@ -32,6 +33,7 @@ class SecretaryService
private readonly SubscriptionService $subscriptionService,
private readonly SmsService $smsService,
private readonly string $appUrl,
private readonly EntityManagerInterface $em,
) {}
/** Find the secretary User by mobile or create it; ensure ROLE_SECRETARY, apply name/password. */
@@ -110,7 +112,7 @@ class SecretaryService
$created[] = $row;
}
$this->secretaryRepo->getEntityManager()->flush(); // flush the batch of new rows
$this->em->flush(); // flush the batch of new rows
if (!empty($created)) {
$this->sendWelcomeSms($mobile, $clinic->getName() ?? 'کلینیک');
@@ -181,7 +183,7 @@ class SecretaryService
}
}
$this->secretaryRepo->getEntityManager()->flush();
$this->em->flush();
return [
'added' => $added,
+31 -1
View File
@@ -37,7 +37,14 @@ trait PermissionGateTrait
* گِیت می‌کند این را بازنویسی می‌کند و بعد `denyUnlessGranted($user, $action)`
* صدا می‌زند؛ کنترلری که چند منبع دارد `denyUnlessGrantedOn()` را مستقیم می‌زند.
*/
abstract private function permissionResource(): string;
private function permissionResource(): string
{
throw new \LogicException(sprintf(
'%s باید permissionResource() را بازنویسی کند، یا به‌جای denyUnlessGranted() '
. 'از denyUnlessGrantedOn() با منبعِ صریح استفاده کند.',
static::class,
));
}
/** @param 'view'|'create'|'update'|'delete'|'cancel'|'update_status' $action */
private function denyUnlessGranted(User $user, string $action): void
@@ -55,4 +62,27 @@ trait PermissionGateTrait
$this->secretaryAccess->denyUnlessGranted($user, $resource, $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, $resource, $action);
}
/**
* پیش‌چکِ منشی — برای کنترلری که چکِ اصلی‌اش به **خودِ رکورد** نیاز دارد.
*
* یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷: در ده‌ها روت، گِیت بعد از `findByUuid()` می‌نشیند،
* پس uuidِ ناموجود اول `404` می‌گیرد و همان تفاوتِ ۴۰۳/۴۰۴ به کاربرِ بی‌مجوز
* می‌گوید کدام رکورد در این محیط وجود دارد.
*
* چکِ اصلی را نمی‌شود بالا برد: به `$clinic` یا `$doctor`ِ همان رکورد نیاز دارد.
* ولی سهمِ منشیِ آن چک **همیشه** همین توگل است، پس این پیش‌چک اکیداً ضعیف‌تر
* است — هر کسی را که رد کند، چکِ پایین‌تر هم رد می‌کرد. یعنی هیچ مسیرِ مجازی
* بسته نمی‌شود و فقط ۴۰۴ به ۴۰۳ تبدیل می‌شود.
*
* عمداً `ClinicDoctorAccessChecker` را صدا نمی‌زند: پزشکِ عضو ممکن است روی
* رکوردِ **کلینیکِ دیگری** که مالکش است اقدام کند، و آنجا محیطِ فعال با محیطِ
* رکورد یکی نیست. آن حالت را فقط چکِ شیء‌محورِ پایین می‌تواند درست بسنجد.
*
* @param 'view'|'create'|'update'|'delete'|'cancel'|'update_status' $action
*/
private function denySecretaryWithout(User $user, string $resource, string $action): void
{
$this->secretaryAccess->denyUnlessGranted($user, $resource, $action);
}
}