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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { Routes, Route } from 'react-router';
|
import { Routes, Route } from 'react-router';
|
||||||
import { renderWithProviders } from '@/test/utils';
|
import { renderWithProviders } from '@/test/utils';
|
||||||
|
|
||||||
vi.mock('@ckeditor/ckeditor5-react', () => ({ CKEditor: () => null }));
|
// ادیتور در jsdom بالا نمیآید و به این تست ربطی ندارد؛ کلِ wrapper mock میشود
|
||||||
vi.mock('@ckeditor/ckeditor5-build-classic', () => ({ default: {} }));
|
// تا mockهای پکیجهای داخلیاش با هر مهاجرت CKEditor عوض نشوند.
|
||||||
|
vi.mock('../components/RichTextEditor', () => ({ default: () => null }));
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
vi.mock('@/components/ui/SearchableSelect', () => ({ default: () => null }));
|
vi.mock('@/components/ui/SearchableSelect', () => ({ default: () => null }));
|
||||||
vi.mock('@/lib/api', () => ({
|
vi.mock('@/lib/api', () => ({
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CKEditor } from '@ckeditor/ckeditor5-react';
|
import RichTextEditor from '../components/RichTextEditor';
|
||||||
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
|
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import type { Blog, City } from '../types';
|
import type { Blog, City } from '../types';
|
||||||
@@ -174,18 +173,7 @@ export default function BlogFormPage() {
|
|||||||
control={control}
|
control={control}
|
||||||
name="body"
|
name="body"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div dir="rtl" className="ck-rtl">
|
<RichTextEditor value={field.value ?? ''} onChange={field.onChange} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.body && <p className="text-[var(--danger)] text-xs mt-1">{errors.body.message}</p>}
|
{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 { useForm, Controller } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CKEditor } from '@ckeditor/ckeditor5-react';
|
import RichTextEditor from '../components/RichTextEditor';
|
||||||
import ClassicEditor from '@ckeditor/ckeditor5-build-classic';
|
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import type { ApiResponse } from '../lib/api';
|
import type { ApiResponse } from '../lib/api';
|
||||||
import type { Blog } from '../types';
|
import type { Blog } from '../types';
|
||||||
@@ -117,14 +116,7 @@ export default function RepresentationBlogFormPage() {
|
|||||||
control={control}
|
control={control}
|
||||||
name="body"
|
name="body"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<div dir="rtl" className="ck-rtl">
|
<RichTextEditor value={field.value ?? ''} onChange={field.onChange} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{errors.body && <p className="text-[var(--danger)] text-xs mt-1">{errors.body.message}</p>}
|
{errors.body && <p className="text-[var(--danger)] text-xs mt-1">{errors.body.message}</p>}
|
||||||
|
|||||||
@@ -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; }
|
.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); } }
|
@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 ────────────────────────────────────────────────────────────
|
/* ── CKEditor 5 ────────────────────────────────────────────────────────────
|
||||||
ادیتور همهٔ رنگهایش را از متغیرهای --ck-color-* خودش میگیرد و پیشفرض آنها
|
ادیتور همهٔ رنگهایش را از متغیرهای --ck-color-* خودش میگیرد و پیشفرض آنها
|
||||||
روشن است؛ بدون این نگاشت، ادیتور در [data-theme="dark"] سفید میماند. */
|
روشن است؛ بدون این نگاشت، ادیتور در [data-theme="dark"] سفید میماند. */
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ framework:
|
|||||||
div: []
|
div: []
|
||||||
a: ['href', 'title', 'target', 'rel']
|
a: ['href', 'title', 'target', 'rel']
|
||||||
img: ['src', 'alt', 'title', 'width', 'height']
|
img: ['src', 'alt', 'title', 'width', 'height']
|
||||||
table: []
|
# سه attributeِ ظاهریِ قدیمیِ جدول عمداً مجازند. هیچکدام
|
||||||
|
# نمیتوانند حاملِ اسکریپت یا URL باشند — مقدارشان عدد است — و
|
||||||
|
# بدونشان جدولِ صدها مقالهٔ موجود حاشیه و فاصلهاش را از دست
|
||||||
|
# میداد. `style` همچنان ممنوع است: تنها attributeِ ظاهریِ این
|
||||||
|
# جمع که میتواند بارِ اجرایی حمل کند.
|
||||||
|
table: ['border', 'cellpadding', 'cellspacing']
|
||||||
thead: []
|
thead: []
|
||||||
tbody: []
|
tbody: []
|
||||||
tfoot: []
|
tfoot: []
|
||||||
|
|||||||
+42
-1
@@ -296,7 +296,7 @@ Create a new blog post.
|
|||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `title` | string | ✅ | Post title (slug auto-generated) |
|
| `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 |
|
| `summary` | string | ❌ | Short excerpt |
|
||||||
| `tags` | integer[] | ❌ | Array of tag IDs |
|
| `tags` | integer[] | ❌ | Array of tag IDs |
|
||||||
| `status` | string | ❌ | `"draft"` (default) or `"published"` |
|
| `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 خروجی)
|
## باطلسازی کش سایت عمومی (webhook خروجی)
|
||||||
|
|
||||||
سایت عمومی (`nobat724_front`) پاسخ `GET /api/v1/blog/{slug}` را با `next: { revalidate: 3600, tags: [...] }` کش میکند. بدون باطلسازی، هر تغییر در پنل ادمین تا یک ساعت روی سایت دیده نمیشد.
|
سایت عمومی (`nobat724_front`) پاسخ `GET /api/v1/blog/{slug}` را با `next: { revalidate: 3600, tags: [...] }` کش میکند. بدون باطلسازی، هر تغییر در پنل ادمین تا یک ساعت روی سایت دیده نمیشد.
|
||||||
|
|||||||
@@ -21,24 +21,27 @@ authz/headers/cors/inject) + پروبهای دستی با JWT واقعی هر
|
|||||||
| 1 | `TreatmentProtocolController` هیچ گِیت مجوزی نداشت — منشیِ `services:false` میتوانست پروتکل درمان را بخواند، بازنویسی و حذف کند | 🟧 High | ✅ رفع شد |
|
| 1 | `TreatmentProtocolController` هیچ گِیت مجوزی نداشت — منشیِ `services:false` میتوانست پروتکل درمان را بخواند، بازنویسی و حذف کند | 🟧 High | ✅ رفع شد |
|
||||||
| 2 | `react-router` — ۵ advisory از جمله XSS و open redirect | 🟧 High | ✅ رفع شد (مهاجرت به v8) |
|
| 2 | `react-router` — ۵ advisory از جمله XSS و open redirect | 🟧 High | ✅ رفع شد (مهاجرت به v8) |
|
||||||
| 3 | `lodash-es` — code injection در `_.template` + دو prototype pollution | 🟧 High | ✅ رفع شد (override به 4.18.1) |
|
| 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 هنگام ذخیره) |
|
| 5 | `dangerouslySetInnerHTML` روی بدنهٔ بلاگ در `BlogReviewPage` | 🟨 Medium | ✅ رفع شد (sanitize هنگام ذخیره) |
|
||||||
| 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ✅ رفع شد |
|
| 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ✅ رفع شد |
|
||||||
| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | ✅ رفع شد (به env منتقل شد) |
|
| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | ✅ رفع شد (به env منتقل شد) |
|
||||||
| 8 | ۱۰ روت `GET` که مجوزِ رجیستریشان را enforce نمیکنند | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
|
| 8 | ۱۰ روت `GET` که مجوزِ رجیستریشان را enforce نمیکنند | 🟨 Medium | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
|
||||||
| 9 | `AppointmentPlanController` هیچ گِیت مجوزی نداشت — دوقلوی یافتهٔ ۱ | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
|
| 9 | `AppointmentPlanController` هیچ گِیت مجوزی نداشت — دوقلوی یافتهٔ ۱ | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
|
||||||
| 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ⚠️ باز — فهرست کامل زیر |
|
| 10 | ۳۴ روت نوشتنی که گِیتشان **بعد از** واکشی رکورد است | 🟦 Low | ◐ نیمهرفع — ۲۲ روت بسته شد، ۱۲ روت باز |
|
||||||
|
| 11 | `MyAppointmentsController::$branches` تزریق نشده بود — اتصال نوبت به جلسهٔ درمان همیشه ۵۰۰ میداد | 🟧 High | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
|
||||||
|
| 12 | سیاست پاکسازی، `style` جدول را میبرد — رگرسیونِ ظاهریِ ناشی از رفعِ یافتهٔ ۵ | 🟦 Low | ✅ رفع شد — ۲۰۲۶-۰۸-۰۸ |
|
||||||
|
|
||||||
سیاست اولیه «فقط Critical/High رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافتههای باز را خواست، پس
|
سیاست اولیه «فقط Critical/High رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافتههای باز را خواست، پس
|
||||||
یافتههای ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۴ طبق تصمیم صریح خارج از محدوده ماند و یافتهٔ ۸
|
یافتههای ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۸ حین همین کار کشف شد.
|
||||||
حین همین کار کشف شد.
|
|
||||||
|
|
||||||
یافتههای ۹ و ۱۰ در جلسهٔ ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافتهٔ ۸. شرحشان در بخش
|
یافتههای ۹ تا ۱۲ در جلسههای ۲۰۲۶-۰۸-۰۸ کشف شدند، حین بستنِ یافتههای ۸ و ۴. شرحشان در بخش
|
||||||
«پیگیری ۲۰۲۶-۰۸-۰۸» انتهای همین سند است.
|
«پیگیری ۲۰۲۶-۰۸-۰۸» انتهای همین سند است.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm audit --omit=dev # قبل: high=2 moderate=62 بعد: high=0 moderate=3
|
npm audit --omit=dev # ۲۰۲۶-۰۸-۰۷ قبل: high=2 moderate=62 · بعد: high=0 moderate=3
|
||||||
ddev exec php bin/phpunit # ۱۵۵۵ تست، ۴۸۳۲ assertion، سبز
|
# ۲۰۲۶-۰۸-۰۸ پس از مهاجرت 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 میرود و
|
برخلاف پیشنهاد ۳ گزارش، پرسنل در 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 شدن ` ` |
|
||||||
|
| حذف 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/Resource/Controller/ResourcePermissionTrait.php روی گِیت مشترک سوار شد
|
||||||
src/Billing/Controller/BillingController.php ۱۲ روت → payments.view/create/update
|
src/Billing/Controller/BillingController.php ۱۲ روت → payments.* + تبدیل نوعِ فیلترها
|
||||||
src/Appointment/Controller/MyAppointmentsController.php ۵ روت → appointments.view/create
|
src/Appointment/Controller/MyAppointmentsController.php ۵ روت → appointments.* + تزریق AddressResolver
|
||||||
src/Appointment/Plan/Controller/AppointmentPlanController.php ۳ روت → services.* (یافتهٔ ۹)
|
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/Staff/StaffCrossTenantTest.php IDOR بینمحیطیِ پرسنل (جدید)
|
||||||
|
tests/Appointment/AppointmentTreatmentSessionLinkTest.php رگرسیونِ یافتهٔ ۱۱ (جدید)
|
||||||
|
tests/Blog/BlogBodySanitizerTest.php attributeهای جدول
|
||||||
tests/Secretary/SecretaryAppointmentScopeTest.php منشیِ بیرابطه: خالی → ۴۰۳
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+1923
-11133
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -35,7 +35,6 @@
|
|||||||
"webpack-cli": "^6.0.0"
|
"webpack-cli": "^6.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ckeditor/ckeditor5-build-classic": "^44.3.0",
|
|
||||||
"@ckeditor/ckeditor5-react": "^11.2.0",
|
"@ckeditor/ckeditor5-react": "^11.2.0",
|
||||||
"@fontsource/vazirmatn": "^5.2.8",
|
"@fontsource/vazirmatn": "^5.2.8",
|
||||||
"@heroicons/react": "^2.0.0",
|
"@heroicons/react": "^2.0.0",
|
||||||
@@ -44,6 +43,7 @@
|
|||||||
"@tanstack/react-table": "^8.0.0",
|
"@tanstack/react-table": "^8.0.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"altcha": "^3.2.0",
|
"altcha": "^3.2.0",
|
||||||
|
"ckeditor5": "^48.4.0",
|
||||||
"jalaali-js": "^1.2.8",
|
"jalaali-js": "^1.2.8",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
+3
-21
@@ -6,24 +6,12 @@ parameters:
|
|||||||
count: 1
|
count: 1
|
||||||
path: src/Appointment/Controller/AppointmentController.php
|
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\.$#'
|
message: '#^Strict comparison using \!\=\= between mixed and null will always evaluate to true\.$#'
|
||||||
identifier: notIdentical.alwaysTrue
|
identifier: notIdentical.alwaysTrue
|
||||||
count: 1
|
count: 1
|
||||||
path: src/Appointment/Service/SlotCalculatorService.php
|
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\(\)\.$#'
|
message: '#^Call to an undefined method Symfony\\Component\\Security\\Core\\User\\UserInterface\:\:getId\(\)\.$#'
|
||||||
identifier: method.notFound
|
identifier: method.notFound
|
||||||
@@ -109,10 +97,10 @@ parameters:
|
|||||||
path: src/Insurance/Controller/InsuranceController.php
|
path: src/Insurance/Controller/InsuranceController.php
|
||||||
|
|
||||||
-
|
-
|
||||||
message: '#^Property App\\Patient\\Service\\PatientService\:\:\$userRepo is never read, only written\.$#'
|
message: '#^Property App\\Patient\\Entity\\PatientRecord\:\:\$tags on left side of \?\?\= is not nullable nor uninitialized\.$#'
|
||||||
identifier: property.onlyWritten
|
identifier: nullCoalesce.initializedProperty
|
||||||
count: 1
|
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\.$#'
|
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
|
count: 2
|
||||||
path: src/Payment/Service/CircuitBreakerService.php
|
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\>\.$#'
|
message: '#^Call to protected method getEntityManager\(\) of class Doctrine\\ORM\\EntityRepository\<object\>\.$#'
|
||||||
identifier: method.protected
|
identifier: method.protected
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
class ResourceBlockController extends BaseController
|
class ResourceBlockController extends BaseController
|
||||||
{
|
{
|
||||||
|
use \App\Shared\Controller\PermissionGateTrait;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ClinicResourceRepository $resources,
|
private readonly ClinicResourceRepository $resources,
|
||||||
private readonly ResourceOccupancyRepository $occupancy,
|
private readonly ResourceOccupancyRepository $occupancy,
|
||||||
@@ -63,6 +65,11 @@ class ResourceBlockController extends BaseController
|
|||||||
#[Route('/api/v1/resource/{uuid}/blocks', name: 'resource_block_create', methods: ['POST'])]
|
#[Route('/api/v1/resource/{uuid}/blocks', name: 'resource_block_create', methods: ['POST'])]
|
||||||
public function create(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
public function create(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->denySecretaryWithout($user, 'resources', 'create');
|
||||||
|
|
||||||
$resource = $this->requireResource($user, $uuid);
|
$resource = $this->requireResource($user, $uuid);
|
||||||
$data = json_decode($request->getContent(), true);
|
$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'])]
|
#[Route('/api/v1/resource-block/{uuid}', name: 'resource_block_delete', methods: ['DELETE'])]
|
||||||
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
|
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->denySecretaryWithout($user, 'resources', 'delete');
|
||||||
|
|
||||||
$block = $this->occupancy->findOneBy(['uuid' => $uuid]);
|
$block = $this->occupancy->findOneBy(['uuid' => $uuid]);
|
||||||
[$entityType, $entityId] = $this->branches->pair($user);
|
[$entityType, $entityId] = $this->branches->pair($user);
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|||||||
#[OA\Tag(name: 'Appointments')]
|
#[OA\Tag(name: 'Appointments')]
|
||||||
class AppointmentController extends BaseController
|
class AppointmentController extends BaseController
|
||||||
{
|
{
|
||||||
|
use \App\Shared\Controller\PermissionGateTrait;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AppointmentRepository $appointmentRepo,
|
private readonly AppointmentRepository $appointmentRepo,
|
||||||
private readonly DoctorRepository $doctorRepo,
|
private readonly DoctorRepository $doctorRepo,
|
||||||
@@ -1042,6 +1044,11 @@ class AppointmentController extends BaseController
|
|||||||
#[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])]
|
#[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])]
|
||||||
public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->denySecretaryWithout($user, 'appointments', 'update_status');
|
||||||
|
|
||||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||||
if ($appointment === null) {
|
if ($appointment === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||||
@@ -1131,6 +1138,11 @@ class AppointmentController extends BaseController
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function confirm(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function confirm(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->denySecretaryWithout($user, 'appointments', 'update_status');
|
||||||
|
|
||||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||||
if ($appointment === null) {
|
if ($appointment === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||||
@@ -1214,6 +1226,11 @@ class AppointmentController extends BaseController
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->denySecretaryWithout($user, 'appointments', 'update_status');
|
||||||
|
|
||||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||||
if ($appointment === null) {
|
if ($appointment === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
||||||
@@ -1396,6 +1413,11 @@ class AppointmentController extends BaseController
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function serviceReschedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function serviceReschedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->denySecretaryWithout($user, 'appointments', 'update_status');
|
||||||
|
|
||||||
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
$appointment = $this->appointmentRepo->findByUuid($uuid);
|
||||||
if ($appointment === null) {
|
if ($appointment === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404);
|
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'])]
|
#[Route('/api/v1/appointment-settings/weekly-schedule', methods: ['POST'])]
|
||||||
public function createSchedule(Request $request, #[CurrentUser] User $user): JsonResponse
|
public function createSchedule(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
|
||||||
|
// همان رکورد نیاز دارد و بالا نمیرود؛ ولی سهمِ منشی از آن همیشه همین
|
||||||
|
// توگل است، پس این خط هیچ مسیرِ مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳
|
||||||
|
// تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
|
||||||
|
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||||
|
|
||||||
@@ -243,6 +249,12 @@ class AppointmentSettingsController extends BaseController
|
|||||||
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['PATCH'])]
|
#[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['PATCH'])]
|
||||||
public function updateSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
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) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
|
|
||||||
// uuid may be doctor uuid or schedule uuid
|
// uuid may be doctor uuid or schedule uuid
|
||||||
@@ -325,6 +337,12 @@ class AppointmentSettingsController extends BaseController
|
|||||||
#[Route('/api/v1/booking-setting/{uuid}', methods: ['DELETE'])]
|
#[Route('/api/v1/booking-setting/{uuid}', methods: ['DELETE'])]
|
||||||
public function deleteSchedule(string $uuid, #[CurrentUser] User $user): JsonResponse
|
public function deleteSchedule(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
|
||||||
|
// همان رکورد نیاز دارد و بالا نمیرود؛ ولی سهمِ منشی از آن همیشه همین
|
||||||
|
// توگل است، پس این خط هیچ مسیرِ مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳
|
||||||
|
// تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
|
||||||
|
|
||||||
$schedule = $this->scheduleRepo->findByUuid($uuid);
|
$schedule = $this->scheduleRepo->findByUuid($uuid);
|
||||||
if ($schedule === null) {
|
if ($schedule === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404);
|
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'])]
|
#[Route('/api/v1/appointment-settings/date-override', methods: ['POST'])]
|
||||||
public function createOverride(Request $request, #[CurrentUser] User $user): JsonResponse
|
public function createOverride(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
|
||||||
|
// همان رکورد نیاز دارد و بالا نمیرود؛ ولی سهمِ منشی از آن همیشه همین
|
||||||
|
// توگل است، پس این خط هیچ مسیرِ مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳
|
||||||
|
// تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
|
||||||
|
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||||
$dateStr = trim($data['date'] ?? '');
|
$dateStr = trim($data['date'] ?? '');
|
||||||
@@ -399,6 +423,12 @@ class AppointmentSettingsController extends BaseController
|
|||||||
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['PATCH'])]
|
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['PATCH'])]
|
||||||
public function updateOverride(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function updateOverride(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
|
||||||
|
// همان رکورد نیاز دارد و بالا نمیرود؛ ولی سهمِ منشی از آن همیشه همین
|
||||||
|
// توگل است، پس این خط هیچ مسیرِ مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳
|
||||||
|
// تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
|
||||||
|
|
||||||
$override = $this->overrideRepo->findByUuid($uuid);
|
$override = $this->overrideRepo->findByUuid($uuid);
|
||||||
if ($override === null) {
|
if ($override === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
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'])]
|
#[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['DELETE'])]
|
||||||
public function deleteOverride(string $uuid, #[CurrentUser] User $user): JsonResponse
|
public function deleteOverride(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
|
||||||
|
// همان رکورد نیاز دارد و بالا نمیرود؛ ولی سهمِ منشی از آن همیشه همین
|
||||||
|
// توگل است، پس این خط هیچ مسیرِ مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳
|
||||||
|
// تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
|
||||||
|
|
||||||
$override = $this->overrideRepo->findByUuid($uuid);
|
$override = $this->overrideRepo->findByUuid($uuid);
|
||||||
if ($override === null) {
|
if ($override === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404);
|
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'])]
|
#[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['DELETE'])]
|
||||||
public function deleteHoliday(string $uuid, #[CurrentUser] User $user): JsonResponse
|
public function deleteHoliday(string $uuid, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
|
||||||
|
// همان رکورد نیاز دارد و بالا نمیرود؛ ولی سهمِ منشی از آن همیشه همین
|
||||||
|
// توگل است، پس این خط هیچ مسیرِ مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳
|
||||||
|
// تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
|
||||||
|
|
||||||
$holiday = $this->holidayRepo->findByUuid($uuid);
|
$holiday = $this->holidayRepo->findByUuid($uuid);
|
||||||
if ($holiday === null) {
|
if ($holiday === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
|
||||||
@@ -501,6 +543,12 @@ class AppointmentSettingsController extends BaseController
|
|||||||
#[Route('/api/v1/appointment-settings/holidays', methods: ['POST'])]
|
#[Route('/api/v1/appointment-settings/holidays', methods: ['POST'])]
|
||||||
public function createHoliday(Request $request, #[CurrentUser] User $user): JsonResponse
|
public function createHoliday(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
|
||||||
|
// همان رکورد نیاز دارد و بالا نمیرود؛ ولی سهمِ منشی از آن همیشه همین
|
||||||
|
// توگل است، پس این خط هیچ مسیرِ مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳
|
||||||
|
// تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
|
||||||
|
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
$doctorUuid = trim($data['doctor_uuid'] ?? '');
|
||||||
$startStr = trim($data['start_date'] ?? '');
|
$startStr = trim($data['start_date'] ?? '');
|
||||||
@@ -541,6 +589,12 @@ class AppointmentSettingsController extends BaseController
|
|||||||
#[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['PATCH'])]
|
#[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['PATCH'])]
|
||||||
public function updateHoliday(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function updateHoliday(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از هر واکشی. چکِ اصلی (denyDoctorAccess) به پزشکِ
|
||||||
|
// همان رکورد نیاز دارد و بالا نمیرود؛ ولی سهمِ منشی از آن همیشه همین
|
||||||
|
// توگل است، پس این خط هیچ مسیرِ مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳
|
||||||
|
// تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', 'update');
|
||||||
|
|
||||||
$holiday = $this->holidayRepo->findByUuid($uuid);
|
$holiday = $this->holidayRepo->findByUuid($uuid);
|
||||||
if ($holiday === null) {
|
if ($holiday === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404);
|
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\Resource\Service\ResourceBookingSlotService $resourceSlots,
|
||||||
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
|
private readonly \App\Appointment\Availability\Service\ResourceOccupier $occupier,
|
||||||
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
|
private readonly \App\Treatment\Service\SessionBookingLink $sessionLink,
|
||||||
|
// اتصال نوبت به جلسهٔ درمان جفتِ [entityType, entityId] میخواهد. تا پیش از
|
||||||
|
// ۲۰۲۶-۰۸-۰۸ همینجا صدا زده میشد ولی هرگز تزریق نشده بود، پس هر درخواستِ
|
||||||
|
// دارای `treatment_session_uuid` روی «Undefined property» ۵۰۰ میگرفت.
|
||||||
|
private readonly \App\Doctor\Service\AddressResolver $branches,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -760,7 +760,7 @@ class AuthController extends BaseController
|
|||||||
if ($rel->getOwnerType() === \App\Secretary\Entity\DoctorSecretary::OWNER_CLINIC && $rel->getClinic() !== null) {
|
if ($rel->getOwnerType() === \App\Secretary\Entity\DoctorSecretary::OWNER_CLINIC && $rel->getClinic() !== null) {
|
||||||
// scope کلینیک — یک context به ازای هر کلینیک (نه هر دکتر)
|
// scope کلینیک — یک context به ازای هر کلینیک (نه هر دکتر)
|
||||||
$clinicUuid = $rel->getClinic()->getUuid();
|
$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)) {
|
if (empty($alreadyAdded)) {
|
||||||
$contexts[] = [
|
$contexts[] = [
|
||||||
'type' => 'clinic',
|
'type' => 'clinic',
|
||||||
|
|||||||
@@ -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
|
private function paymentFilters(Request $request): array
|
||||||
{
|
{
|
||||||
|
// `from`/`to` مهرِ زمانیاند و قرارداد سرویس `int` میخواهد. تبدیل همینجا
|
||||||
|
// انجام میشود، در مرزِ ورودی، نه در repository — وگرنه هر مصرفکنندهٔ تازه
|
||||||
|
// باید همان cast را تکرار کند.
|
||||||
|
$timestamp = static fn (?string $raw): ?int => ($raw ?: null) === null ? null : (int) $raw;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'national_code' => $request->query->get('national_code') ?: null,
|
'national_code' => $request->query->get('national_code') ?: null,
|
||||||
'status' => $request->query->get('status') ?: null,
|
'status' => $request->query->get('status') ?: null,
|
||||||
'from' => $request->query->get('from') ?: null,
|
'from' => $timestamp($request->query->get('from')),
|
||||||
'to' => $request->query->get('to') ?: null,
|
'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 (` ` → 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')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_info', 'update');
|
||||||
|
|
||||||
$clinic = $this->clinicRepo->findByUuid($uuid);
|
$clinic = $this->clinicRepo->findByUuid($uuid);
|
||||||
if ($clinic === null) {
|
if ($clinic === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||||
@@ -385,6 +390,11 @@ class ClinicController extends BaseController
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function detachDoctor(string $clinicUuid, string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
public function detachDoctor(string $clinicUuid, string $doctorUuid, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'delete');
|
||||||
|
|
||||||
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
|
||||||
if ($clinic === null) {
|
if ($clinic === null) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ class ClinicDoctorPermissionController extends BaseController
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function updatePermissions(string $clinicUuid, string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
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');
|
$clinic = $this->resolveClinic($clinicUuid, $user, 'update');
|
||||||
$doctor = $this->resolveMember($clinic, $doctorUuid);
|
$doctor = $this->resolveMember($clinic, $doctorUuid);
|
||||||
$perm = $this->permRepo->getOrCreate($clinic, $doctor);
|
$perm = $this->permRepo->getOrCreate($clinic, $doctor);
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ class ClinicInvitationController extends BaseController
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function inviteDoctor(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function inviteDoctor(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'create');
|
||||||
|
|
||||||
$clinic = $this->clinicRepo->findByUuid($uuid);
|
$clinic = $this->clinicRepo->findByUuid($uuid);
|
||||||
if (!$clinic) {
|
if (!$clinic) {
|
||||||
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
|
throw new AppException('ERR_NOT_FOUND_001', 'کلینیک یافت نشد', 404);
|
||||||
@@ -92,6 +97,11 @@ class ClinicInvitationController extends BaseController
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function resendInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
|
public function resendInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'create');
|
||||||
|
|
||||||
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
||||||
if (!$inv) {
|
if (!$inv) {
|
||||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||||
@@ -107,6 +117,11 @@ class ClinicInvitationController extends BaseController
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function changeInvitationStatus(string $invUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
public function changeInvitationStatus(string $invUuid, Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'update');
|
||||||
|
|
||||||
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
||||||
if (!$inv) {
|
if (!$inv) {
|
||||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||||
@@ -126,6 +141,11 @@ class ClinicInvitationController extends BaseController
|
|||||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||||
public function deleteInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
|
public function deleteInvitation(string $invUuid, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// پیشچکِ منشی، پیش از واکشیِ رکورد. چکِ اصلی به خودِ شیء نیاز دارد و بالا
|
||||||
|
// نمیرود؛ سهمِ منشی از آن اما همیشه همین توگل است، پس این خط هیچ مسیرِ
|
||||||
|
// مجازی نمیبندد و فقط ۴۰۴ را به ۴۰۳ تبدیل میکند. یافتهٔ ۱۰ آدیت ۲۰۲۶-۰۸-۰۷.
|
||||||
|
$this->secretaryAccess->denyUnlessGranted($user, 'clinic_doctors', 'delete');
|
||||||
|
|
||||||
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
$inv = $this->invRepo->findOneBy(['uuid' => $invUuid]);
|
||||||
if (!$inv) {
|
if (!$inv) {
|
||||||
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه یافت نشد', 404);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use App\Insurance\Entity\TenantServiceCoverage;
|
|||||||
use App\Insurance\Enum\ServiceCategory;
|
use App\Insurance\Enum\ServiceCategory;
|
||||||
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||||||
use App\Secretary\Security\SecretaryAccessChecker;
|
use App\Secretary\Security\SecretaryAccessChecker;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use App\ClinicService\Repository\CatalogCategoryRepository;
|
use App\ClinicService\Repository\CatalogCategoryRepository;
|
||||||
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
|
use App\ClinicService\Repository\ServiceItemAuditLogRepository;
|
||||||
use App\ClinicService\Repository\ServiceItemRepository;
|
use App\ClinicService\Repository\ServiceItemRepository;
|
||||||
@@ -46,7 +45,6 @@ class ClinicServiceController extends BaseController
|
|||||||
private readonly InventoryItemRepository $inventoryItemRepo,
|
private readonly InventoryItemRepository $inventoryItemRepo,
|
||||||
private readonly ServiceItemAuditService $auditService,
|
private readonly ServiceItemAuditService $auditService,
|
||||||
private readonly ServiceItemAuditLogRepository $auditLogRepo,
|
private readonly ServiceItemAuditLogRepository $auditLogRepo,
|
||||||
private readonly EntityManagerInterface $em,
|
|
||||||
private readonly EntityContextResolver $contextResolver,
|
private readonly EntityContextResolver $contextResolver,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private readonly SecretaryAccessChecker $secretaryAccess,
|
private readonly SecretaryAccessChecker $secretaryAccess,
|
||||||
|
|||||||
@@ -147,6 +147,10 @@ class ServiceItem
|
|||||||
public function getConsumables(): Collection
|
public function getConsumables(): Collection
|
||||||
{
|
{
|
||||||
// Doctrine بدون constructor هیدریت میکند؛ از property تایپشده محافظت کن.
|
// Doctrine بدون constructor هیدریت میکند؛ از property تایپشده محافظت کن.
|
||||||
|
// phpstan فقط constructor را میبیند و میگوید این property همیشه مقدار
|
||||||
|
// دارد. Doctrine اما بدون constructor هیدریت میکند، پس روی نمونهٔ
|
||||||
|
// نیمهساخته میتواند initialize نشده باشد. گارد عمدی است.
|
||||||
|
/** @phpstan-ignore-next-line */
|
||||||
return $this->consumables ??= new ArrayCollection();
|
return $this->consumables ??= new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +213,10 @@ class ServiceItem
|
|||||||
public function getStaffMembers(): Collection
|
public function getStaffMembers(): Collection
|
||||||
{
|
{
|
||||||
// Doctrine hydrates without the constructor; guard the typed property.
|
// Doctrine hydrates without the constructor; guard the typed property.
|
||||||
|
// phpstan فقط constructor را میبیند و میگوید این property همیشه مقدار
|
||||||
|
// دارد. Doctrine اما بدون constructor هیدریت میکند، پس روی نمونهٔ
|
||||||
|
// نیمهساخته میتواند initialize نشده باشد. گارد عمدی است.
|
||||||
|
/** @phpstan-ignore-next-line */
|
||||||
return $this->staffMembers ??= new ArrayCollection();
|
return $this->staffMembers ??= new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class DoctorClaimService
|
|||||||
|
|
||||||
private function finalize(Doctor $doctor, User $target, DoctorClaimRequest $claim, ?string $nationalCode): void
|
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);
|
$locked = $this->em->find(Doctor::class, $doctor->getId(), LockMode::PESSIMISTIC_WRITE);
|
||||||
|
|
||||||
if ($locked->getOwnerStatus() !== 'pending_transfer') {
|
if ($locked->getOwnerStatus() !== 'pending_transfer') {
|
||||||
@@ -170,8 +170,7 @@ class DoctorClaimService
|
|||||||
});
|
});
|
||||||
|
|
||||||
// حذف امن جانشین — پس از flush انتقال، تا شمارش پزشکانِ متصل قطعی باشد
|
// حذف امن جانشین — پس از flush انتقال، تا شمارش پزشکانِ متصل قطعی باشد
|
||||||
if ($surrogate !== null
|
if ($surrogate->getId() !== $target->getId()
|
||||||
&& $surrogate->getId() !== $target->getId()
|
|
||||||
&& $surrogate->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)
|
&& $surrogate->hasRole(DoctorImportService::ROLE_UNCLAIMED_DOCTOR)
|
||||||
&& $this->em->getRepository(Doctor::class)->count(['user' => $surrogate]) === 0) {
|
&& $this->em->getRepository(Doctor::class)->count(['user' => $surrogate]) === 0) {
|
||||||
$this->em->remove($surrogate);
|
$this->em->remove($surrogate);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use App\Inventory\Entity\InventoryItem;
|
|||||||
use App\Inventory\Entity\InventoryPackage;
|
use App\Inventory\Entity\InventoryPackage;
|
||||||
use App\Inventory\Entity\InventoryPackageItem;
|
use App\Inventory\Entity\InventoryPackageItem;
|
||||||
use App\Inventory\Repository\InventoryItemRepository;
|
use App\Inventory\Repository\InventoryItemRepository;
|
||||||
use App\Inventory\Repository\InventoryPackageRepository;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Inventory domain logic: derived aggregates (stat counters, package totals and
|
* Inventory domain logic: derived aggregates (stat counters, package totals and
|
||||||
@@ -17,7 +16,6 @@ class InventoryService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly InventoryItemRepository $itemRepo,
|
private readonly InventoryItemRepository $itemRepo,
|
||||||
private readonly InventoryPackageRepository $packageRepo,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ namespace App\Patient\Service;
|
|||||||
|
|
||||||
use App\Appointment\Entity\Appointment;
|
use App\Appointment\Entity\Appointment;
|
||||||
use App\Appointment\Service\AppointmentInsuranceService;
|
use App\Appointment\Service\AppointmentInsuranceService;
|
||||||
use App\Auth\Repository\UserRepository;
|
|
||||||
use App\Billing\Service\BillingCalculator;
|
use App\Billing\Service\BillingCalculator;
|
||||||
use App\Billing\ValueObject\Money;
|
use App\Billing\ValueObject\Money;
|
||||||
use App\ClinicService\Repository\ServiceItemRepository;
|
use App\ClinicService\Repository\ServiceItemRepository;
|
||||||
use App\Clinic\Repository\ClinicRepository;
|
|
||||||
use App\Insurance\Enum\ServiceCategory;
|
use App\Insurance\Enum\ServiceCategory;
|
||||||
use App\Insurance\Repository\EntityInsurancePricingRepository;
|
use App\Insurance\Repository\EntityInsurancePricingRepository;
|
||||||
use App\Insurance\Service\TenantInsuranceService;
|
use App\Insurance\Service\TenantInsuranceService;
|
||||||
use App\Inventory\Repository\InventoryItemRepository;
|
use App\Inventory\Repository\InventoryItemRepository;
|
||||||
use App\Inventory\Repository\InventoryPackageRepository;
|
use App\Inventory\Repository\InventoryPackageRepository;
|
||||||
use App\Doctor\Repository\DoctorAddressRepository;
|
|
||||||
use App\Auth\Entity\User;
|
use App\Auth\Entity\User;
|
||||||
use App\Patient\Entity\PatientRecord;
|
use App\Patient\Entity\PatientRecord;
|
||||||
use App\Patient\Entity\PatientSession;
|
use App\Patient\Entity\PatientSession;
|
||||||
@@ -46,10 +43,7 @@ class PatientService
|
|||||||
private readonly InventoryItemRepository $inventoryItemRepo,
|
private readonly InventoryItemRepository $inventoryItemRepo,
|
||||||
private readonly InventoryPackageRepository $inventoryPackageRepo,
|
private readonly InventoryPackageRepository $inventoryPackageRepo,
|
||||||
private readonly ClinicStaffRepository $staffRepo,
|
private readonly ClinicStaffRepository $staffRepo,
|
||||||
private readonly UserRepository $userRepo,
|
|
||||||
private readonly SubscriptionService $subscriptionService,
|
private readonly SubscriptionService $subscriptionService,
|
||||||
private readonly DoctorAddressRepository $addressRepo,
|
|
||||||
private readonly ClinicRepository $clinicRepo,
|
|
||||||
private readonly TenantInsuranceService $tenantInsuranceService,
|
private readonly TenantInsuranceService $tenantInsuranceService,
|
||||||
private readonly AppointmentInsuranceService $appointmentInsurance,
|
private readonly AppointmentInsuranceService $appointmentInsurance,
|
||||||
private readonly BillingCalculator $billingCalculator,
|
private readonly BillingCalculator $billingCalculator,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class RecordNumberGenerator
|
|||||||
if ($token === 'MM') return str_pad((string) $jm, 2, '0', STR_PAD_LEFT);
|
if ($token === 'MM') return str_pad((string) $jm, 2, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
// {SEQ} یا {SEQ:n} — پدینگ سقف نیست: شمارندهٔ بلندتر از n بریده نمیشود.
|
// {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);
|
return str_pad((string) $counter, $width, '0', STR_PAD_LEFT);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ class ResourceController extends BaseController
|
|||||||
private function dayStart(?string $date): ?int
|
private function dayStart(?string $date): ?int
|
||||||
{
|
{
|
||||||
if ($date === null || $date === '') {
|
if ($date === null || $date === '') {
|
||||||
return strtotime('today midnight') ?: null;
|
return strtotime('today midnight');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use App\Secretary\Repository\DoctorSecretaryRepository;
|
|||||||
use App\Sms\Entity\SmsLog;
|
use App\Sms\Entity\SmsLog;
|
||||||
use App\Sms\Service\SmsService;
|
use App\Sms\Service\SmsService;
|
||||||
use App\Subscription\Service\SubscriptionService;
|
use App\Subscription\Service\SubscriptionService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,6 +33,7 @@ class SecretaryService
|
|||||||
private readonly SubscriptionService $subscriptionService,
|
private readonly SubscriptionService $subscriptionService,
|
||||||
private readonly SmsService $smsService,
|
private readonly SmsService $smsService,
|
||||||
private readonly string $appUrl,
|
private readonly string $appUrl,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Find the secretary User by mobile or create it; ensure ROLE_SECRETARY, apply name/password. */
|
/** Find the secretary User by mobile or create it; ensure ROLE_SECRETARY, apply name/password. */
|
||||||
@@ -110,7 +112,7 @@ class SecretaryService
|
|||||||
$created[] = $row;
|
$created[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->secretaryRepo->getEntityManager()->flush(); // flush the batch of new rows
|
$this->em->flush(); // flush the batch of new rows
|
||||||
|
|
||||||
if (!empty($created)) {
|
if (!empty($created)) {
|
||||||
$this->sendWelcomeSms($mobile, $clinic->getName() ?? 'کلینیک');
|
$this->sendWelcomeSms($mobile, $clinic->getName() ?? 'کلینیک');
|
||||||
@@ -181,7 +183,7 @@ class SecretaryService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->secretaryRepo->getEntityManager()->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'added' => $added,
|
'added' => $added,
|
||||||
|
|||||||
@@ -37,7 +37,14 @@ trait PermissionGateTrait
|
|||||||
* گِیت میکند این را بازنویسی میکند و بعد `denyUnlessGranted($user, $action)`
|
* گِیت میکند این را بازنویسی میکند و بعد `denyUnlessGranted($user, $action)`
|
||||||
* صدا میزند؛ کنترلری که چند منبع دارد `denyUnlessGrantedOn()` را مستقیم میزند.
|
* صدا میزند؛ کنترلری که چند منبع دارد `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 */
|
/** @param 'view'|'create'|'update'|'delete'|'cancel'|'update_status' $action */
|
||||||
private function denyUnlessGranted(User $user, string $action): void
|
private function denyUnlessGranted(User $user, string $action): void
|
||||||
@@ -55,4 +62,27 @@ trait PermissionGateTrait
|
|||||||
$this->secretaryAccess->denyUnlessGranted($user, $resource, $action);
|
$this->secretaryAccess->denyUnlessGranted($user, $resource, $action);
|
||||||
$this->clinicDoctorAccess->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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ abstract class ApiTestCase extends WebTestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->em = static::getContainer()->get(EntityManagerInterface::class);
|
$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();
|
$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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,30 @@ class BlogBodySanitizerTest extends ApiTestCase
|
|||||||
$this->assertStringContainsString('ویرایش', $body);
|
$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 ناامن ندارد، بعد از پاکسازی خالی است → ۴۲۲، نه ذخیره. */
|
/** بدنهای که چیزی جز markup ناامن ندارد، بعد از پاکسازی خالی است → ۴۲۲، نه ذخیره. */
|
||||||
public function testBodyThatIsOnlyUnsafeMarkupIsRejected(): void
|
public function testBodyThatIsOnlyUnsafeMarkupIsRejected(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -160,14 +160,44 @@ class ApiLeastPrivilegeTest extends ApiTestCase
|
|||||||
'app_clinicservice_clinicservice_deletesection' => 'حذف ممنوع — همیشه ۴۰۹',
|
'app_clinicservice_clinicservice_deletesection' => 'حذف ممنوع — همیشه ۴۰۹',
|
||||||
|
|
||||||
// ── گِیت دارند ولی هدفشان از بدنه میآید، نه از path ──────────────────
|
// ── گِیت دارند ولی هدفشان از بدنه میآید، نه از path ──────────────────
|
||||||
// با بدنهٔ خالی روی uuidِ ناموجودِ داخلِ بدنه ۴۰۴ میدهند. مثل روتهای
|
|
||||||
// پارامتردار، ولی چون path parameter ندارند سطح اولِ قاعده شاملشان میشد.
|
|
||||||
'app_appointment_appointmentsettings_createschedule' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
|
|
||||||
'app_appointment_appointmentsettings_createoverride' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
|
|
||||||
'app_appointment_appointmentsettings_createholiday' => 'پزشکِ هدف از بدنه؛ گِیت در denyDoctorAccess',
|
|
||||||
'app_secretary_secretary_create' => 'پزشکِ هدف از بدنه؛ مالکیت در canManage سنجیده میشود',
|
'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` تا انتهای تست به یک نمونهٔ کهنه اشاره میکند. بدون
|
* میآورد، پس `$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 اضافه شد، این
|
* بدهی نباید بیصدا بماند: بهمحض اینکه گِیتِ یکی از KNOWN_GAPS اضافه شد، این
|
||||||
* تست قرمز میشود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه
|
* تست قرمز میشود تا آن ردیف از فهرست حذف شود. بدون این، فهرست برای همیشه
|
||||||
|
|||||||
@@ -38,6 +38,19 @@ use App\Treatment\Entity\TreatmentSession;
|
|||||||
*/
|
*/
|
||||||
class StaffCrossTenantTest extends ApiTestCase
|
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 = [
|
private const LASER_SCHEMA = [
|
||||||
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true, 'sort_order' => 0],
|
['key' => 'shots', 'label' => 'شات', 'type' => 'number', 'required' => true, 'sort_order' => 0],
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user