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
@@ -0,0 +1,78 @@
import React from 'react';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import {
ClassicEditor,
Essentials,
Paragraph,
Heading,
Bold,
Italic,
Link,
List,
BlockQuote,
Table,
TableToolbar,
Undo,
} from 'ckeditor5';
import translations from 'ckeditor5/translations/fa.js';
import 'ckeditor5/ckeditor5.css';
/**
* ادیتور متن غنی مقاله‌ها.
*
* تا ۲۰۲۶-۰۸-۰۸ هر دو صفحهٔ مقاله مستقیم `@ckeditor/ckeditor5-build-classic` را
* import می‌کردند. آن پکیج deprecated بود و ۶۲ advisory داشت (یافتهٔ ۴ آدیت
* ۲۰۲۶-۰۸-۰۷). جایگزینش پکیج umbrella `ckeditor5` است که در آن، برخلاف build
* آماده، فهرست پلاگین‌ها صریح است.
*
* پیکربندی اینجا متمرکز شد تا مهاجرت بعدی یک فایل باشد نه دو صفحه — و تا نوار
* ابزارِ دو صفحه از هم واگرا نشود.
*
* فهرست پلاگین‌ها دقیقاً همان دکمه‌های نوار ابزارِ قبلی است، نه بیشتر: هر پلاگین
* اضافه یعنی markup تازه‌ای که `html_sanitizer.yaml` هنوز مجازش نکرده و هنگام
* ذخیره حذف می‌شود.
*/
export default function RichTextEditor({
value,
onChange,
}: {
value: string;
onChange: (html: string) => void;
}) {
return (
<div dir="rtl" className="ck-rtl">
<CKEditor
editor={ClassicEditor}
data={value}
onChange={(_evt, editor) => onChange(editor.getData())}
config={{
licenseKey: 'GPL',
language: 'fa',
translations: [translations],
plugins: [
Essentials,
Paragraph,
Heading,
Bold,
Italic,
Link,
List,
BlockQuote,
Table,
TableToolbar,
Undo,
],
toolbar: [
'heading', '|',
'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|',
'blockQuote', 'insertTable', '|',
'undo', 'redo',
],
table: {
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells'],
},
}}
/>
</div>
);
}
+3 -2
View File
@@ -4,8 +4,9 @@ import userEvent from '@testing-library/user-event';
import { Routes, Route } from 'react-router';
import { renderWithProviders } from '@/test/utils';
vi.mock('@ckeditor/ckeditor5-react', () => ({ CKEditor: () => null }));
vi.mock('@ckeditor/ckeditor5-build-classic', () => ({ default: {} }));
// ادیتور در jsdom بالا نمی‌آید و به این تست ربطی ندارد؛ کلِ wrapper mock می‌شود
// تا mockهای پکیج‌های داخلی‌اش با هر مهاجرت CKEditor عوض نشوند.
vi.mock('../components/RichTextEditor', () => ({ default: () => null }));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock('@/components/ui/SearchableSelect', () => ({ default: () => null }));
vi.mock('@/lib/api', () => ({
+2 -14
View File
@@ -4,8 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
import RichTextEditor from '../components/RichTextEditor';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { Blog, City } from '../types';
@@ -174,18 +173,7 @@ export default function BlogFormPage() {
control={control}
name="body"
render={({ field }) => (
<div dir="rtl" className="ck-rtl">
<CKEditor
editor={ClassicEditor as never}
data={field.value ?? ''}
onChange={(_evt: unknown, editor: { getData: () => string }) => field.onChange(editor.getData())}
config={{
licenseKey: 'GPL',
language: 'fa',
toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', 'blockQuote', 'insertTable', '|', 'undo', 'redo'],
}}
/>
</div>
<RichTextEditor value={field.value ?? ''} onChange={field.onChange} />
)}
/>
{errors.body && <p className="text-[var(--danger)] text-xs mt-1">{errors.body.message}</p>}
@@ -3,8 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CKEditor } from '@ckeditor/ckeditor5-react';
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
import RichTextEditor from '../components/RichTextEditor';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import type { Blog } from '../types';
@@ -117,14 +116,7 @@ export default function RepresentationBlogFormPage() {
control={control}
name="body"
render={({ field }) => (
<div dir="rtl" className="ck-rtl">
<CKEditor
editor={ClassicEditor as never}
data={field.value ?? ''}
onChange={(_e: unknown, editor: { getData: () => string }) => field.onChange(editor.getData())}
config={{ licenseKey: 'GPL', language: 'fa', toolbar: ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', '|', 'blockQuote', 'insertTable', '|', 'undo', 'redo'] }}
/>
</div>
<RichTextEditor value={field.value ?? ''} onChange={field.onChange} />
)}
/>
{errors.body && <p className="text-[var(--danger)] text-xs mt-1">{errors.body.message}</p>}
+10
View File
@@ -1020,6 +1020,16 @@ html, body { max-width: 100%; overflow-x: hidden; }
.wh-two-col { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--gap); align-items: start; }
@media (max-width: 1100px) { .wh-two-col { grid-template-columns: minmax(0, 1fr); } }
/* ── بدنهٔ مقاله در صفحهٔ بازبینی ───────────────────────────────────────────
جدول‌های مقاله‌ها تا ۲۰۲۶-۰۸-۰۸ ظاهرشان را از `style` inline می‌گرفتند. آن
attribute در sanitizer عمداً ممنوع است — تنها attributeِ ظاهریِ جدول که
می‌تواند بارِ اجرایی حمل کند — پس همان ظاهر اینجا، در لایهٔ درست، بازسازی
می‌شود. `border` و `cellpadding` هنوز از خودِ HTML می‌آیند. */
.blog-body table { border-collapse: collapse; width: 100%; }
.blog-body th, .blog-body td { border: 1px solid var(--border); padding: 8px; }
.blog-body th { background: var(--surface-2); font-weight: 600; }
.blog-body img { max-width: 100%; height: auto; }
/* ── CKEditor 5 ────────────────────────────────────────────────────────────
ادیتور همهٔ رنگ‌هایش را از متغیرهای --ck-color-* خودش می‌گیرد و پیش‌فرض آن‌ها
روشن است؛ بدون این نگاشت، ادیتور در [data-theme="dark"] سفید می‌ماند. */
+6 -1
View File
@@ -45,7 +45,12 @@ framework:
div: []
a: ['href', 'title', 'target', 'rel']
img: ['src', 'alt', 'title', 'width', 'height']
table: []
# سه attributeِ ظاهریِ قدیمیِ جدول عمداً مجازند. هیچ‌کدام
# نمی‌توانند حاملِ اسکریپت یا URL باشند — مقدارشان عدد است — و
# بدونشان جدولِ صدها مقالهٔ موجود حاشیه و فاصله‌اش را از دست
# می‌داد. `style` همچنان ممنوع است: تنها attributeِ ظاهریِ این
# جمع که می‌تواند بارِ اجرایی حمل کند.
table: ['border', 'cellpadding', 'cellspacing']
thead: []
tbody: []
tfoot: []
+42 -1
View File
@@ -296,7 +296,7 @@ Create a new blog post.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | ✅ | Post title (slug auto-generated) |
| `body` | string | ✅ | Full HTML body (from the admin CKEditor) |
| `body` | string | ✅ | Full HTML body (from the admin CKEditor). **پیش از ذخیره پاک‌سازی می‌شود** — [سیاست پاک‌سازی بدنه](#سیاست-پاکسازی-بدنه). |
| `summary` | string | ❌ | Short excerpt |
| `tags` | integer[] | ❌ | Array of tag IDs |
| `status` | string | ❌ | `"draft"` (default) or `"published"` |
@@ -582,6 +582,47 @@ Upload blog post header image.
---
## سیاست پاک‌سازی بدنه
بدنهٔ مقاله در **لحظهٔ ذخیره** پاک‌سازی می‌شود، نه هنگام نمایش — هر چهار نقطهٔ ورود
(`POST`/`PATCH` در `BlogController` و `RepresentationBlogController`) از
`App\Blog\Service\BlogBodySanitizer` می‌گذرند. دلیلش چند مصرف‌کننده بودنِ بدنه است:
پنل ادمین، سایت عمومی و فید. با پاک‌سازی در لایهٔ نمایش، هر مصرف‌کنندهٔ تازه دوباره
آسیب‌پذیر شروع می‌کرد.
سیاست در `config/packages/html_sanitizer.yaml` است. سه قاعده‌ای که رفتار قابل‌مشاهده
دارند:
- `<script>`, `<style>`, `<iframe>`, `<object>`, `<embed>`, `<form>`, `<input>`,
`<button>`, `<noscript>` با محتوایشان **حذف** می‌شوند (`drop` نه `block`) — وگرنه
`<script>alert(1)</script>` به متنِ `alert(1)` تبدیل می‌شد.
- روی هر `<a>` مقدار `rel="noopener noreferrer"` **تحمیل** می‌شود؛ طرح‌های مجاز فقط
`http`, `https`, `mailto`.
- `<table>` سه attributeِ ظاهریِ قدیمی را نگه می‌دارد — `border`, `cellpadding`,
`cellspacing` — ولی `style` را از **هر** عنصری حذف می‌کند. ظاهرِ جدولِ بدنهٔ مقاله
در پنل از `.blog-body` در `assets/admin/styles.css` می‌آید.
بدنه‌ای که پس از پاک‌سازی خالی شود `422` می‌گیرد، نه اینکه خالی ذخیره شود.
### پاک‌سازی مقاله‌های قدیمی
مقاله‌هایی که پیش از این سیاست ذخیره شده‌اند با یک دستور به همان وضع می‌رسند:
```bash
ddev exec php bin/console app:blog:sanitize-bodies --dry-run # گزارش، بدون نوشتن
ddev exec php bin/console app:blog:sanitize-bodies # اعمال
ddev exec php bin/console app:blog:sanitize-bodies --show=42 # قبل/بعدِ یک مقاله
```
گزارشِ dry-run تغییرها را تفکیک می‌کند: «سخت‌سازی» یعنی فقط `rel` اضافه یا entity
decode شده، و «حذفِ تگ یا attribute غیرمجاز» یعنی آن مقاله markupی داشته که سیاست
نمی‌پذیرد. بدونِ این تفکیک، عددِ کلِ تغییرات گمراه‌کننده است — در اجرای ۲۰۲۶-۰۸-۰۸ از
۴۲۶ مقالهٔ تغییریافته، ۳۹۸ فقط سخت‌سازی بودند.
دستور idempotent است: اجرای دوم صفر تغییر گزارش می‌دهد.
---
## باطل‌سازی کش سایت عمومی (webhook خروجی)
سایت عمومی (`nobat724_front`) پاسخ `GET /api/v1/blog/{slug}` را با `next: { revalidate: 3600, tags: [...] }` کش می‌کند. بدون باطل‌سازی، هر تغییر در پنل ادمین تا یک ساعت روی سایت دیده نمی‌شد.
+174 -12
View File
@@ -21,24 +21,27 @@ authz/headers/cors/inject) + پروب‌های دستی با JWT واقعی هر
| 1 | `TreatmentProtocolController` هیچ گِیت مجوزی نداشت — منشیِ `services:false` می‌توانست پروتکل درمان را بخواند، بازنویسی و حذف کند | 🟧 High | ✅ رفع شد |
| 2 | `react-router`۵ advisory از جمله XSS و open redirect | 🟧 High | ✅ رفع شد (مهاجرت به v8) |
| 3 | `lodash-es` — code injection در `_.template` + دو prototype pollution | 🟧 High | ✅ رفع شد (override به 4.18.1) |
| 4 | ۶۲ moderate در `@ckeditor/ckeditor5-build-classic` (deprecated) | 🟨 Medium | ⚠️ risk پذیرفته‌شده تصمیم ۲۰۲۶-۰۸-۰۷ |
| 4 | ۶۲ moderate در `@ckeditor/ckeditor5-build-classic` (deprecated) | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ (مهاجرت به `ckeditor5@48`) |
| 5 | `dangerouslySetInnerHTML` روی بدنهٔ بلاگ در `BlogReviewPage` | 🟨 Medium | ✅ رفع شد (sanitize هنگام ذخیره) |
| 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ✅ رفع شد |
| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | ✅ رفع شد (به env منتقل شد) |
| 8 | ۱۰ روت `GET` که مجوزِ رجیستری‌شان را enforce نمی‌کنند | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 9 | `AppointmentPlanController` هیچ گِیت مجوزی نداشت — دوقلوی یافتهٔ ۱ | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ⚠️ باز — فهرست کامل زیر |
| 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ◐ نیمه‌رفع — ۲۲ روت بسته شد، ۱۲ روت باز |
| 11 | `MyAppointmentsController::$branches` تزریق نشده بود — اتصال نوبت به جلسهٔ درمان همیشه ۵۰۰ می‌داد | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
| 12 | سیاست پاک‌سازی، `style` جدول را می‌برد — رگرسیونِ ظاهریِ ناشی از رفعِ یافتهٔ ۵ | 🟦 Low | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
سیاست اولیه «فقط Critical/High رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافته‌های باز را خواست، پس
یافته‌های ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۴ طبق تصمیم صریح خارج از محدوده ماند و یافتهٔ ۸
حین همین کار کشف شد.
یافته‌های ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۸ حین همین کار کشف شد.
یافته‌های ۹ و ۱۰ در جلسهٔ ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافتهٔ ۸. شرحشان در بخش
یافته‌های ۹ تا ۱۲ در جلسه‌های ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافته‌های ۸ و ۴. شرحشان در بخش
«پیگیری ۲۰۲۶-۰۸-۰۸» انتهای همین سند است.
```bash
npm audit --omit=dev # قبل: high=2 moderate=62 بعد: high=0 moderate=3
ddev exec php bin/phpunit # ۱۵۵۵ تست، ۴۸۳۲ assertion، سبز
npm audit --omit=dev # ۲۰۲۶-۰۸-۰۷ قبل: high=2 moderate=62 · بعد: high=0 moderate=3
# ۲۰۲۶-۰۸-۰۸ پس از مهاجرت CKEditor: ۰ آسیب‌پذیری
ddev exec php bin/phpunit # ۱۵۶۸ تست سبز (بود ۱۵۵۵)
ddev exec php vendor/bin/phpstan # No errors (بود ۱۷ خطا بیرون از baseline)
```
---
@@ -681,18 +684,177 @@ fail-closed مستندِ `SecretaryPermissionChecker`. «خالیِ خاموش»
برخلاف پیشنهاد ۳ گزارش، پرسنل در DB زنده ساخته **نشد**: دادهٔ دستی با اولین re-seed می‌رود و
هیچ‌وقت خودکار اجرا نمی‌شود.
## یافتهٔ ۱۱ 🟧 HIGH — `$branches` تزریق‌نشده: اتصال نوبت به جلسهٔ درمان همیشه ۵۰۰
**۱. فایل:** `src/Appointment/Controller/MyAppointmentsController.php:384`
**۲. شرح:** کنترلر `$this->branches->pair($user)` را صدا می‌زد ولی `AddressResolver` هرگز در
constructor نبود. هر `POST /api/v1/my/appointment` که `treatment_session_uuid` داشت روی
«Undefined property» می‌افتاد. یعنی اتصال نوبت به جلسهٔ درمان از پنل **هیچ‌وقت کار نکرده**.
**۳. چرا کسی ندید:** این شاخه هیچ تستی نداشت. `phpstan` دقیقاً همین را گزارش می‌کرد، ولی بین
۱۶ خطای بی‌اثرِ دیگر گم شده بود — «۱۷ خطا» عددی ثابت شده بود که همه ازش رد می‌شدند. درسش این
است که baselineِ نخوانده، باگ زنده را پنهان می‌کند.
**۴. رفع:** تزریق `AddressResolver $branches` — همان سرویسی که `AppointmentPlanController` و
`TreatmentProtocolController` با همین نام استفاده می‌کنند.
**۵. تست:** `tests/Appointment/AppointmentTreatmentSessionLinkTest.php` — سه سناریو: بدون
اتصال (۲۰۱)، uuidِ ناموجود (۴۰۴ با `treatment_session_uuid` در فیلد خطا، نه ۵۰۰)، و رشتهٔ
خالی (۲۰۱).
## یافتهٔ ۱۲ 🟦 LOW — رگرسیونِ ظاهریِ ناشی از رفعِ یافتهٔ ۵
**۱. شرح:** سیاست `html_sanitizer.yaml` برای `<table>` هیچ attributeی مجاز نکرده بود. از آنجا
که پاک‌سازی در **لحظهٔ ذخیره** است، هر مقاله‌ای که از پنل ویرایش می‌شد حاشیه و فاصلهٔ جدولش را
از دست می‌داد. رگرسیون از ۲۰۲۶-۰۸-۰۷ فعال بود و کسی ندیده بود، چون هنوز کسی مقالهٔ جدول‌دار را
ویرایش نکرده بود.
**۲. رفع:** `border`, `cellpadding`, `cellspacing` مجاز شدند — هر سه عددی و غیرقابل‌اجرا.
`style` عمداً ممنوع ماند و ظاهرِ از دست رفته (`border-collapse`, `width`) در `.blog-body`
داخل `assets/admin/styles.css` بازسازی شد؛ یعنی presentation به لایهٔ درستش رفت.
**۳. تست:** `BlogBodySanitizerTest::testTableKeepsInertLayoutAttributesButLosesStyle`.
**۴. کارِ باقی‌مانده در repo دیگر:** `nobat724_front` همان بدنه را رندر می‌کند و قاعدهٔ CSS
معادل را ندارد. جدولِ مقاله‌ها آنجا بدون `border-collapse` نمایش داده می‌شود.
## یافتهٔ ۴ — بسته شد: مهاجرت CKEditor
`@ckeditor/ckeditor5-build-classic@44.3.0` (deprecated، ۶۲ advisory) با پکیج umbrella
`ckeditor5@48.4.0` جایگزین شد. `@ckeditor/ckeditor5-react@11.2.0` از قبل نصب بود و
peer dependency اش `ckeditor5 >= 46` است، پس bump دیگری لازم نشد.
مهاجرت کم‌ریسک بود چون سطح مصرف کوچک است: دو صفحه، یک `ClassicEditor`، ده دکمهٔ نوار ابزار.
پیکربندی در کامپوننت مشترک `assets/admin/components/RichTextEditor.tsx` متمرکز شد تا مهاجرت
بعدی یک فایل باشد و نوار ابزارِ دو صفحه از هم واگرا نشود.
در پکیج umbrella، برخلاف buildِ آماده، فهرست پلاگین‌ها باید صریح باشد. عمداً دقیقاً همان
پلاگین‌های دکمه‌های قبلی آورده شد و نه بیشتر: هر پلاگین اضافه یعنی markup تازه‌ای که
`html_sanitizer.yaml` مجازش نکرده و هنگام ذخیره حذف می‌شود.
```
npm audit --omit=dev # قبل: 61 (moderate=3, low=58) → بعد: 0
npx tsc --noEmit # بدون خطا
ddev exec yarn dev # webpack compiled successfully — 54 فایل
npx vitest --run # ۸۰۱ تست فرانت‌اند سبز
```
## یافتهٔ ۱۰ — نیمه‌رفع: ۲۲ روت از ۳۴ بسته شد
چکِ اصلیِ این روت‌ها شیء‌محور است و بالا نمی‌رود — `ClinicController::update` به `$clinic`ِ همان
رکورد نیاز دارد. ولی **سهمِ منشی** از آن چک همیشه همان توگلِ رجیستری است، پس یک پیش‌چکِ
فقط-منشی اکیداً ضعیف‌تر است: هر کسی را که رد کند، چکِ پایین‌تر هم رد می‌کرد. یعنی هیچ مسیرِ
مجازی بسته نمی‌شود و فقط ۴۰۴ به ۴۰۳ تبدیل می‌شود.
`ClinicDoctorAccessChecker` عمداً در پیش‌چک نیست: پزشکِ عضو ممکن است روی رکوردِ کلینیکِ دیگری
که مالکش است اقدام کند، و آنجا محیطِ فعال با محیطِ رکورد یکی نیست. آن حالت را فقط چکِ
شیء‌محورِ پایین می‌تواند درست بسنجد.
بسته شد (۲۲): `AppointmentController` × ۴ · `AppointmentSettingsController` × ۹ ·
`ClinicController::update`, `detachDoctor` · `ClinicDoctorPermissionController` ×۱ ·
`ClinicInvitationController` × ۴ · `ResourceBlockController` × ۲.
باز ماند (۱۲) — هیچ‌کدام منبعی در `PermissionCatalog` ندارند و چکشان مالکیتِ خودِ رکورد است:
- `ClinicController``createAddress`, `updateAddress`, `deleteAddress`. `addresses` فقط
`view` دارد؛ نوشتنِ آدرس صریحاً owner-or-admin است و اصلاً قابل واگذاری نیست.
- `DoctorController``update`, `delete`, `updateAddress`, `deleteAddress`.
- `RepresentationController::update`.
- `SecretaryController``create`, `update`, `deactivate`, `syncClinicDoctors`.
پوششِ نشتِ باقی‌مانده کم‌ارزش است: uuidِ پزشک از فهرست عمومی پزشکان در دسترس است. برای منشی و
نماینده ارزشش بیشتر است ولی همچنان Low. بستنشان یا کلیدِ تازه در رجیستری می‌خواهد — که UI
مجوزها و دو Entity و تایپ‌های فرانت را درگیر می‌کند — یا یک مکانیزم موازیِ نقش‌محور، که یافتهٔ ۱
توصیه کرد نسازیم.
`ApiLeastPrivilegeTest::testHoistedGatesAnswer403BeforeTheLookup` این ۲۲ تا را قفل می‌کند: اگر
کسی خطِ پیش‌چک را بردارد، پاسخ به ۴۰۴ برمی‌گردد و تست قرمز می‌شود.
## پاک‌سازی `phpstan` — و آنچه زیرش پنهان بود
هر ۱۷ خطای بیرون از baseline بسته شد و `phpstan` حالا `No errors` می‌دهد. baseline از ۴۴ به
۴۳ ردیف رسید. جنس خطاها:
- **۱ باگ زنده** — یافتهٔ ۱۱ بالا.
- **۳ ناهم‌خوانی نوع** — `BillingController` فیلترهای `from`/`to` را string می‌فرستاد و
`InvoiceService` عددِ صحیح می‌خواست. تبدیل در مرزِ ورودی نشست، نه در repository.
- **۶ property تزریق‌شده و بلااستفاده** در پنج سرویس — حذف شدند.
- **۲ فراخوانیِ `getEntityManager()` از بیرون** در `SecretaryService` — با `EntityManagerInterface`
تزریق‌شده جایگزین شد.
- **۲ ignore pattern کهنه** که دیگر با هیچ خطایی مطابقت نداشتند.
- **۳ مقایسهٔ همیشه-درست** — ساده شدند.
دو مورد عمداً با `@phpstan-ignore-next-line` ماندند: `ServiceItem::getConsumables()` و
`getStaffMembers()`. `phpstan` فقط constructor را می‌بیند و می‌گوید property همیشه مقدار دارد؛
Doctrine اما بدون constructor هیدریت می‌کند. گاردِ `??=` عمدی است.
## پاک‌سازی مقاله‌های قدیمی
`app:blog:sanitize-bodies` ساخته شد و روی هر ۴۲۷ مقاله اجرا شد. دستور است نه migration، چون
سیاست ممکن است دوباره سفت شود و آن‌وقت باید همین گذر تکرار شود.
عددِ خام گمراه‌کننده بود: «۴۲۶ مقاله تغییر می‌کند» در نگاه اول یعنی ۴۲۶ مقالهٔ آلوده. تفکیکِ
جنسِ تغییر نشان داد:
| جنس تغییر | تعداد | یعنی چه |
|---|---|---|
| سخت‌سازی | ۳۹۸ | افزودن `rel="noopener noreferrer"` یا decode شدن `&nbsp;` |
| حذف attribute | ۲۸ | فقط `style` روی ۲۲ جدول و ۱ `div`؛ بقیه ترمیمِ HTML شکسته |
| بدون تغییر | ۱ | — |
**هیچ مقاله‌ای markup اجرایی نداشت** — نه `<script>`، نه `onerror`، نه `javascript:`. یعنی
یافتهٔ ۵ یک ریسک بالقوه را بست، نه یک نشتِ فعال را.
پس از اجرا، ۴۲۵ مقاله `rel="noopener noreferrer"` دارند و هیچ‌کدام `style=` ندارند. اجرای دوم
صفر تغییر گزارش می‌دهد.
## ناپایداریِ سوییت — ریشه‌اش پیدا و بسته شد
گزارش ۲۰۲۶-۰۸-۰۷ نوشته بود یک تست، ۲۰۰ تست بعدتر تستی بی‌ربط را می‌شکند، و آن را با
`resetManager()` در `tearDown` همان تست مهار کرده بود. مهار بود نه رفع: با اضافه‌شدنِ
تست‌های این جلسه، خطا سه بار روی سه تستِ متفاوت ظاهر شد — `ClinicRecordAccessTest`،
`ServiceRescheduleTest`، `CommentListNPlusOneTest` — و هر سه در اجرای تکی سبز بودند.
ریشه، بسته‌شدنِ manager نبود. تستی که موجودیتی را `persist()` می‌کند و بی‌`flush()` تمام
می‌شود، همان unit of work را برای تست بعدی به ارث می‌گذارد؛ آنجا اولین `flush()` با
«A new entity was found through the relationship …» می‌شکند. `setUp` فقط وقتی ریست می‌کرد
که manager **بسته** باشد، و این حالت manager را باز ولی آلوده می‌گذارد.
رفع: یک `$this->em->clear()` در `ApiTestCase::setUp`. عمداً `clear()` نه `resetManager()`
همان نمونه می‌ماند، پس هیچ ارجاعی به managerِ مرده نمی‌رسد و فقط identity map خالی می‌شود.
دو اجرای کاملِ پیاپی سبز شد.
## تغییرات این جلسه
```
src/Shared/Controller/PermissionGateTrait.php گِیت مشترک (جدید)
src/Shared/Controller/PermissionGateTrait.php گِیت مشترک + پیش‌چکِ منشی (جدید)
src/Resource/Controller/ResourcePermissionTrait.php روی گِیت مشترک سوار شد
src/Billing/Controller/BillingController.php ۱۲ روت → payments.view/create/update
src/Appointment/Controller/MyAppointmentsController.php ۵ روت → appointments.view/create
src/Billing/Controller/BillingController.php ۱۲ روت → payments.* + تبدیل نوعِ فیلترها
src/Appointment/Controller/MyAppointmentsController.php ۵ روت → appointments.* + تزریق AddressResolver
src/Appointment/Plan/Controller/AppointmentPlanController.php ۳ روت → services.* (یافتهٔ ۹)
tests/Shared/ApiLeastPrivilegeTest.php پویش نوشتنی + ALLOWED_WRITE؛ KNOWN_GAPS خالی شد
src/Appointment/Controller/AppointmentController.php ۴ پیش‌چک (یافتهٔ ۱۰)
src/Appointment/Controller/AppointmentSettingsController.php ۹ پیش‌چک
src/Clinic/Controller/ClinicController.php ۲ پیش‌چک
src/Clinic/Controller/ClinicDoctorPermissionController.php ۱ پیش‌چک
src/ClinicInvitation/Controller/ClinicInvitationController.php ۴ پیش‌چک
src/Appointment/Availability/Controller/ResourceBlockController.php ۲ پیش‌چک
src/Blog/Command/SanitizeBlogBodiesCommand.php پاک‌سازی مقاله‌های قدیمی (جدید)
config/packages/html_sanitizer.yaml سه attributeِ ظاهریِ جدول مجاز شد
assets/admin/components/RichTextEditor.tsx ادیتور مشترک روی ckeditor5@48 (جدید)
assets/admin/pages/BlogFormPage.tsx استفاده از ادیتور مشترک
assets/admin/pages/RepresentationBlogFormPage.tsx استفاده از ادیتور مشترک
assets/admin/styles.css .blog-body — ظاهر جدولِ مقاله
package.json ckeditor5@48؛ build-classic حذف شد
phpstan-baseline.neon دو ردیفِ کهنه رفت، ۴۴ → ۴۳
پنج سرویس (Patient/Inventory/Secretary/ClinicService/…) propertyهای بلااستفاده حذف شد
tests/Shared/ApiLeastPrivilegeTest.php پویش نوشتنی + ALLOWED_WRITE + قفلِ GATE_BEFORE_LOOKUP
tests/Staff/StaffCrossTenantTest.php IDOR بین‌محیطیِ پرسنل (جدید)
tests/Appointment/AppointmentTreatmentSessionLinkTest.php رگرسیونِ یافتهٔ ۱۱ (جدید)
tests/Blog/BlogBodySanitizerTest.php attributeهای جدول
tests/Secretary/SecretaryAppointmentScopeTest.php منشیِ بی‌رابطه: خالی → ۴۰۳
docs/api/billing.md · appointment.md · appointment-plan.md مجوز هر روت
tests/ApiTestCase.php clear() در setUp — رفعِ ناپایداریِ سوییت
docs/api/billing.md · appointment.md · appointment-plan.md · blog.md · insurance.md · doctor-service.md
```
---
+1923 -11133
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -35,7 +35,6 @@
"webpack-cli": "^6.0.0"
},
"dependencies": {
"@ckeditor/ckeditor5-build-classic": "^44.3.0",
"@ckeditor/ckeditor5-react": "^11.2.0",
"@fontsource/vazirmatn": "^5.2.8",
"@heroicons/react": "^2.0.0",
@@ -44,6 +43,7 @@
"@tanstack/react-table": "^8.0.0",
"@types/leaflet": "^1.9.21",
"altcha": "^3.2.0",
"ckeditor5": "^48.4.0",
"jalaali-js": "^1.2.8",
"leaflet": "^1.9.4",
"react": "^19.0.0",
+3 -21
View File
@@ -6,24 +6,12 @@ parameters:
count: 1
path: src/Appointment/Controller/AppointmentController.php
-
message: '#^Property App\\Patient\\Entity\\PatientRecord\:\:\$tags on left side of \?\?\= is not nullable nor uninitialized\.$#'
identifier: nullCoalesce.initializedProperty
count: 1
path: src/Patient/Entity/PatientRecord.php
-
message: '#^Strict comparison using \!\=\= between mixed and null will always evaluate to true\.$#'
identifier: notIdentical.alwaysTrue
count: 1
path: src/Appointment/Service/SlotCalculatorService.php
-
message: '#^Offset ''role'' on array\{type\: ''clinic'', db_uuid\: mixed, name\: non\-falsy\-string, role\: ''secretary'', scope\: ''clinic'', permissions\: mixed\}\|array\{type\: ''clinic'', db_uuid\: string, name\: string, role\: ''clinic''\|''doctor'', scope\: ''clinic''\|null, doctor_uuid\: string\}\|array\{type\: ''clinic'', db_uuid\: string, name\: string, role\: ''clinic''\}\|array\{type\: ''doctor'', db_uuid\: mixed, name\: non\-falsy\-string, role\: ''secretary'', scope\: ''doctor'', permissions\: mixed\}\|array\{type\: ''doctor'', db_uuid\: string, name\: non\-falsy\-string, role\: ''doctor''\} on left side of \?\? always exists and is not nullable\.$#'
identifier: nullCoalesce.offset
count: 1
path: src/Auth/Controller/AuthController.php
-
message: '#^Call to an undefined method Symfony\\Component\\Security\\Core\\User\\UserInterface\:\:getId\(\)\.$#'
identifier: method.notFound
@@ -109,10 +97,10 @@ parameters:
path: src/Insurance/Controller/InsuranceController.php
-
message: '#^Property App\\Patient\\Service\\PatientService\:\:\$userRepo is never read, only written\.$#'
identifier: property.onlyWritten
message: '#^Property App\\Patient\\Entity\\PatientRecord\:\:\$tags on left side of \?\?\= is not nullable nor uninitialized\.$#'
identifier: nullCoalesce.initializedProperty
count: 1
path: src/Patient/Service/PatientService.php
path: src/Patient/Entity/PatientRecord.php
-
message: '#^Offset 0 on non\-empty\-list\<string\> on left side of \?\? always exists and is not nullable\.$#'
@@ -138,12 +126,6 @@ parameters:
count: 2
path: src/Payment/Service/CircuitBreakerService.php
-
message: '#^Call to an undefined method Symfony\\Contracts\\Cache\\CacheInterface\:\:getItem\(\)\.$#'
identifier: method.notFound
count: 1
path: src/Shared/Controller/HealthController.php
-
message: '#^Call to protected method getEntityManager\(\) of class Doctrine\\ORM\\EntityRepository\<object\>\.$#'
identifier: method.protected
@@ -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);
}
}
+12
View File
@@ -54,6 +54,18 @@ abstract class ApiTestCase extends WebTestCase
}
$this->em = static::getContainer()->get(EntityManagerInterface::class);
// بستنِ manager تنها راهِ آلوده‌شدنِ تستِ بعدی نیست. تستی که موجودیتی را
// `persist()` می‌کند و بی‌`flush()` تمام می‌شود — یا درخواستِ کرنلی که
// ارجاع‌هایش را نیمه‌کاره رها می‌کند — همان unit of work را برای تستِ بعدی
// به ارث می‌گذارد. آنجا اولین `flush()` با «A new entity was found through
// the relationship …» می‌شکند؛ خطایی که همیشه جای دیگری می‌افتد و در اجرای
// تکی هرگز تکرار نمی‌شود.
//
// `clear()` نه `resetManager()`: همان نمونه می‌ماند، پس هیچ ارجاعی به
// managerِ مرده نمی‌رسد؛ فقط identity map خالی می‌شود.
$this->em->clear();
$this->ensureFreePlan();
}
@@ -0,0 +1,100 @@
<?php
namespace App\Tests\Appointment;
use App\Auth\Entity\User;
use App\Doctor\Entity\Doctor;
use App\Tests\ApiTestCase;
/**
* اتصال نوبت به جلسهٔ درمان در `POST /api/v1/my/appointment`.
*
* تا ۲۰۲۶-۰۸-۰۸ این شاخه هیچ تستی نداشت و به همین دلیل شکسته بود: کنترلر
* `$this->branches->pair($user)` را صدا می‌زد ولی `AddressResolver` هرگز تزریق
* نشده بود، پس هر درخواستِ دارای `treatment_session_uuid` روی «Undefined
* property» ۵۰۰ می‌گرفت. phpstan همان را گزارش می‌کرد، اما بین ۱۶ خطای بی‌اثر
* دیگر گم شده بود.
*/
class AppointmentTreatmentSessionLinkTest extends ApiTestCase
{
/**
* این تست چند درخواستِ کرنل پشت‌سرهم می‌زند و هر کدام `$this->em` را کهنه
* می‌کند. بدون ریست، همان نمونه به تست بعدی ارث می‌رسد و آنجا نه اینجا
* با «Multiple non-persisted new entities» می‌شکند. همان دامی که
* ApiLeastPrivilegeTest قبلاً برایش همین tearDown را گذاشت.
*/
protected function tearDown(): void
{
static::getContainer()->get('doctrine')->resetManager();
parent::tearDown();
}
/** @return array{0: User, 1: Doctor} */
private function doctor(): array
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor);
$this->em->flush();
return [$owner, $doctor];
}
private function body(string $doctorUuid, array $extra = []): array
{
$start = time() + 86_400 + random_int(0, 3_600) * 100;
return $extra + [
'doctor_uuid' => $doctorUuid,
'slot_start' => $start,
'slot_end' => $start + 1_800,
'patient_mobile' => '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT),
'patient_name' => 'بیمار تست',
'patient_national_code' => '00' . str_pad((string) random_int(0, 99_999_999), 8, '0', STR_PAD_LEFT),
];
}
/** موفق: مسیر عادی بدون اتصال، دست‌نخورده. */
public function testBookingWithoutSessionLinkStillWorks(): void
{
[$owner, $doctor] = $this->doctor();
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body($doctor->getUuid()));
self::assertSame(201, $this->responseCode());
}
/**
* خطا: uuidِ ناموجود باید ۴۰۴ـی بگیرد که از `SessionBookingLink` می‌آید.
*
* ۵۰۰ گرفتن یعنی اجرا اصلاً به آن سرویس نرسیده همان رگرسیونی که این تست
* برایش نوشته شده.
*/
public function testBookingWithUnknownSessionUuidIsRejectedNotCrashed(): void
{
[$owner, $doctor] = $this->doctor();
$body = $this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body(
$doctor->getUuid(),
['treatment_session_uuid' => '00000000-0000-0000-0000-000000000000'],
));
self::assertSame(404, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE));
self::assertSame('ERR_NOT_FOUND_001', $body['errors'][0]['code']);
self::assertSame('treatment_session_uuid', $body['errors'][0]['field'] ?? null);
}
/** مرزی: رشتهٔ خالی یعنی «اتصالی در کار نیست»، نه uuidِ نامعتبر. */
public function testEmptySessionUuidIsTreatedAsNoLink(): void
{
[$owner, $doctor] = $this->doctor();
$this->authJson('POST', '/api/v1/my/appointment', $owner, $this->body(
$doctor->getUuid(),
['treatment_session_uuid' => ' '],
));
self::assertSame(201, $this->responseCode());
}
}
+24
View File
@@ -97,6 +97,30 @@ class BlogBodySanitizerTest extends ApiTestCase
$this->assertStringContainsString('ویرایش', $body);
}
/**
* جدول‌های مقاله‌های موجود ظاهرشان را از attributeهای قدیمیِ HTML می‌گیرند.
* این سه غیرقابل‌اجرا هستند و از ۲۰۲۶-۰۸-۰۸ مجازند؛ `style` همچنان می‌رود چون
* تنها attributeِ ظاهریِ جدول است که می‌تواند بارِ اجرایی حمل کند.
*/
public function testTableKeepsInertLayoutAttributesButLosesStyle(): void
{
$admin = $this->createUser(['ROLE_ADMIN']);
$res = $this->authJson('POST', '/api/v1/blog', $admin, [
'title' => 'مقالهٔ جدول',
'body' => '<table border="1" cellpadding="7" cellspacing="0" style="width:100%">'
. '<tr><td>سلول</td></tr></table>',
]);
$uuid = $res['data']['data']['uuid'] ?? $res['data']['uuid'];
$body = $this->storedBody($uuid);
$this->assertStringContainsString('border="1"', $body);
$this->assertStringContainsString('cellpadding="7"', $body);
$this->assertStringContainsString('cellspacing="0"', $body);
$this->assertStringNotContainsString('style=', $body);
$this->assertStringContainsString('سلول', $body);
}
/** بدنه‌ای که چیزی جز markup ناامن ندارد، بعد از پاک‌سازی خالی است → ۴۲۲، نه ذخیره. */
public function testBodyThatIsOnlyUnsafeMarkupIsRejected(): void
{
+67 -5
View File
@@ -160,14 +160,44 @@ class ApiLeastPrivilegeTest extends ApiTestCase
'app_clinicservice_clinicservice_deletesection' => 'حذف ممنوع — همیشه ۴۰۹',
// ── گِیت دارند ولی هدفشان از بدنه می‌آید، نه از path ──────────────────
// با بدنهٔ خالی روی uuidِ ناموجودِ داخلِ بدنه ۴۰۴ می‌دهند. مثل روت‌های
// پارامتردار، ولی چون path parameter ندارند سطح اولِ قاعده شاملشان می‌شد.
'app_appointment_appointmentsettings_createschedule' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_appointment_appointmentsettings_createoverride' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_appointment_appointmentsettings_createholiday' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
'app_secretary_secretary_create' => 'پزشکِ هدف از بدنه؛ مالکیت در canManage سنجیده می‌شود',
];
/**
* روت‌هایی که پیش‌چکِ منشی **پیش از واکشی** دارند، پس حتی با uuidِ ناموجود هم
* باید `403` بدهند نه `404`.
*
* این فهرست پیشرفتِ یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷ را قفل می‌کند: چکِ اصلیِ این روت‌ها
* شیء‌محور است و بالا نمی‌رود، ولی سهمِ منشی از آن بالا برده شد. اگر کسی آن خط را
* بردارد، پاسخ به `404` برمی‌گردد و همین تست قرمز می‌شود.
*
* @var list<string>
*/
private const GATE_BEFORE_LOOKUP = [
'app_appointment_appointment_updatestatus',
'app_appointment_appointment_confirm',
'app_appointment_appointment_update',
'app_appointment_appointment_servicereschedule',
'app_appointment_appointmentsettings_createschedule',
'app_appointment_appointmentsettings_updateschedule',
'app_appointment_appointmentsettings_deleteschedule',
'app_appointment_appointmentsettings_createoverride',
'app_appointment_appointmentsettings_updateoverride',
'app_appointment_appointmentsettings_deleteoverride',
'app_appointment_appointmentsettings_createholiday',
'app_appointment_appointmentsettings_updateholiday',
'app_appointment_appointmentsettings_deleteholiday',
'app_clinic_clinic_update',
'app_clinic_clinic_detachdoctor',
'app_clinic_clinicdoctorpermission_updatepermissions',
'app_clinicinvitation_clinicinvitation_invitedoctor',
'app_clinicinvitation_clinicinvitation_resendinvitation',
'app_clinicinvitation_clinicinvitation_changeinvitationstatus',
'app_clinicinvitation_clinicinvitation_deleteinvitation',
'resource_block_create',
'resource_block_delete',
];
/**
* این تست ~۱۳۰ درخواست پشت‌سرهم می‌زند و هر درخواست کرنل را دوباره بالا
* می‌آورد، پس `$this->em` تا انتهای تست به یک نمونهٔ کهنه اشاره می‌کند. بدون
@@ -344,6 +374,38 @@ class ApiLeastPrivilegeTest extends ApiTestCase
));
}
/**
* قفلِ پیشرفت: هر روتِ `GATE_BEFORE_LOOKUP` با uuidِ ناموجود باید `403` بدهد.
*
* `404` یعنی گِیت دوباره پایین‌تر از واکشی رفته و enumeration oracle برگشته.
*/
public function testHoistedGatesAnswer403BeforeTheLookup(): void
{
$secretary = $this->makePowerlessSecretary();
$router = self::getContainer()->get('router');
$regressed = [];
foreach (self::GATE_BEFORE_LOOKUP as $name) {
$route = $router->getRouteCollection()->get($name);
$this->assertNotNull($route, "روت {$name} دیگر وجود ندارد — فهرست را به‌روز کن");
$method = array_values(array_intersect(
$route->getMethods(),
['POST', 'PUT', 'PATCH', 'DELETE'],
))[0];
$this->authJson($method, self::probePath($route), $secretary);
if ($this->responseCode() !== 403) {
$regressed[] = sprintf('%s → %d', $name, $this->responseCode());
}
}
$this->assertSame([], $regressed, sprintf(
"این روت‌ها دیگر پیش از واکشی گِیت نمی‌خورند:\n%s",
implode("\n", $regressed),
));
}
/**
* بدهی نباید بی‌صدا بماند: به‌محض اینکه گِیتِ یکی از KNOWN_GAPS اضافه شد، این
* تست قرمز می‌شود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه
+13
View File
@@ -38,6 +38,19 @@ use App\Treatment\Entity\TreatmentSession;
*/
class StaffCrossTenantTest extends ApiTestCase
{
/**
* این تست چند درخواستِ کرنل پشت‌سرهم می‌زند و هر کدام `$this->em` را کهنه
* می‌کند. بدون ریست، همان نمونه به تست بعدی ارث می‌رسد و آنجا نه اینجا
* با «Multiple non-persisted new entities» می‌شکند. همان دامی که
* ApiLeastPrivilegeTest قبلاً برایش همین tearDown را گذاشت.
*/
protected function tearDown(): void
{
static::getContainer()->get('doctrine')->resetManager();
parent::tearDown();
}
private const LASER_SCHEMA = [
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true, 'sort_order' => 0],
];
+630 -1464
View File
File diff suppressed because it is too large Load Diff