> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Easy!Appointments: حجز مواعيد العيادات ومراكز الخدمة
- URL: https://arabroot.io/articles/easy-appointments-حجز-مواعيد-العيادات-ومراكز-الخدمة/
- Published: 2026-10-12T15:00:00.000Z
- Updated: 2026-10-11T22:50:50.000Z
- Description: كيف تشغل Easy!Appointments على سيرفرك لحجز الخدمات مع مقدميها، وما الذي يحتاج انتباهك في العربية والمنطقة الزمنية.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, الاستضافة الذاتية والتطبيقات, تطبيقات لفريقك, الاستضافة الذاتية, Easy!Appointments

في [دليل اختيار أداة الحجز](https://arabroot.io/articles/%D8%A7%D9%8A-%D8%A7%D8%AF%D8%A7%D8%A9-%D8%AD%D8%AC%D8%B2-%D9%85%D9%88%D8%A7%D8%B9%D9%8A%D8%AF-%D8%AA%D9%86%D8%A7%D8%B3%D8%A8%D9%83/) فصلنا بين أربع حالات حجز مختلفة، وقلنا إن العيادة ومركز الخدمة يحتاجان أداة مبنية حول الخدمة ومقدمها وجدول عمله، وأن Easy!Appointments هو المرشح لهذه الحالة. وفي هذا المقال من مرحلة [المستندات والنماذج والتوقيع والحجز](https://arabroot.io/start/#stage-office) سوف نشغله على جهازنا بإصدار محدد، ونبني عليه عيادة تجريبية بطبيبين وموظفة استقبال، ثم نحجز كما يحجز المريض، ونقرأ البريد الذي يصل، ونربطه بتقويم CalDAV، ثم نكسره مرتين: مرة بحجز الموعد نفسه من عدة عملاء في اللحظة نفسها، ومرة بفرق المنطقة الزمنية، ونرى أثر كل منهما بالأرقام.

وسوف نناقش في هذا المقال ما يلي:

- مفاهيم Easy!Appointments: الخدمات ومقدموها والسكرتارية، وجداول العمل والاستراحات والإجازات.
- التثبيت بال Docker Compose بإصدارات محددة، ومشكلة واجهتنا في أول تشغيل.
- صفحة الحجز، ولماذا لا تعمل داخل iframe في موقع آخر.
- رسائل البريد مع Mailpit، والمزامنة مع Google Calendar ومع سيرفر CalDAV.
- الواجهة العربية: ما الذي يعمل، وما الذي ينقصها بصراحة.
- ال REST API وال Webhooks، والنسخ الاحتياطي، والحماية.
- تجربتان نكسر فيهما النظام: الحجز المكرر، وفرق المنطقة الزمنية.

## كيف يفكر Easy!Appointments في الحجز؟

Easy!Appointments تطبيق PHP على إطار CodeIgniter مع قاعدة MySQL، وترخيصه [GPL-3.0](https://github.com/alextselegidis/easyappointments/blob/1.6.0/LICENSE?ref=arabroot.io)، واستخدمنا الإصدار المستقر [1.6.0](https://github.com/alextselegidis/easyappointments/releases/tag/1.6.0?ref=arabroot.io). وقبل التثبيت عليك أن تعرف المفاهيم التي يبني عليها كل شيء، لأنك سوف تدخلها بهذا الترتيب:

- **الخدمة Service:** ما يحجزه العميل، ولها مدة وسعر وفاصل بين بدايات المواعيد Slot Interval، ويمكن جمع الخدمات في تصنيفات Categories.
- **مقدم الخدمة Provider:** الطبيب أو الفني، وله حساب دخول، وقائمة بالخدمات التي يقدمها، وجدول عمل Working Plan لكل يوم من الأسبوع مع الاستراحات Breaks، ومنطقة زمنية خاصة به. وتسميه الترجمة العربية «المزود».
- **السكرتارية Secretaries:** موظف استقبال يدير مواعيد مقدمي خدمة محددين، دون أن يملك صلاحيات المدير.
- **العميل Customer:** ينشأ تلقائياً عند أول حجز، وتسميه الترجمة «الزبون».
- **الإجازات والاستثناءات:** فترة «غير متوفر» Unavailability لمقدم خدمة واحد، واستثناء خطة العمل Working Plan Exception ليوم بساعات مختلفة، وفترة محجوبة Blocked Period تغلق الحجز على الجميع، مثل يوم صيانة.

والصورة التالية تبين كيف تتصل الأجزاء في التشغيل الفعلي، ولاحظ أن صفحة الحجز وحدها هي التي تحتاج أن تكون عامة:

![مخطط فيه العميل يصل عبر Reverse Proxy إلى صفحة الحجز العامة، والموظف يصل إلى لوحة الإدارة عبر SSO أو VPN، وداخل Docker Compose تطبيق easyappointments مع MySQL وMailpit، ويخرج منه اتصال مزامنة إلى سيرفر CalDAV وGoogle Calendar لكل مقدم خدمة، وبريد عبر SMTP](https://arabroot.io/content/images/2026/10/easy-appointments-01-architecture.webp)

صفحة الحجز للجميع، ولوحة الإدارة وال API وقاعدة البيانات خلف بوابتك

## التثبيت بال Docker Compose

يوجد Docker Image رسمي من [مستودع easyappointments-docker](https://github.com/alextselegidis/easyappointments-docker?ref=arabroot.io)، يكتب ملف `config.php` وإعدادات البريد من المتغيرات عند كل تشغيل، ويحتاج قاعدة MySQL. وأما ملف `docker-compose.yml` الموجود في المستودع الرئيسي فهو للتطوير فقط بحسب [توثيق Docker](https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/docker.md?ref=arabroot.io)، فلا تقم باستخدامه في الإنتاج. وإذا لم يكن Docker مثبتاً لديك فقد شرحنا [تثبيت Docker على Ubuntu](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-docker-%D8%B9%D9%84%D9%89-ubuntu/). وهذا الملف الذي شغلناه، مع Mailpit لالتقاط البريد بدل إرساله:

```yaml
name: lab-book-ea
services:
  app:
    image: alextselegidis/easyappointments:1.6.0
    container_name: lab-book-ea-app
    restart: unless-stopped
    ports:
      - "127.0.0.1:28410:80"
    environment:
      BASE_URL: "http://127.0.0.1:28410"
      LANGUAGE: "arabic"
      DEBUG_MODE: "FALSE"
      DB_HOST: "db"
      DB_NAME: "easyappointments"
      DB_USERNAME: "ea"
      DB_PASSWORD: "${DB_PASSWORD}"
      MAIL_PROTOCOL: "smtp"
      MAIL_SMTP_AUTH: "0"
      MAIL_SMTP_HOST: "mailpit"
      MAIL_SMTP_PORT: "1025"
      MAIL_SMTP_CRYPTO: ""
      MAIL_FROM_ADDRESS: "booking@clinic.example.com"
      MAIL_FROM_NAME: "عيادة المثال"
      MAIL_REPLY_TO_ADDRESS: "reception@clinic.example.com"
    depends_on:
      db:
        condition: service_healthy
  db:
    image: mysql:8.4.11
    container_name: lab-book-ea-db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}"
      MYSQL_DATABASE: "easyappointments"
      MYSQL_USER: "ea"
      MYSQL_PASSWORD: "${DB_PASSWORD}"
    volumes:
      - db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${DB_ROOT_PASSWORD}"]
      interval: 5s
      retries: 30
      start_period: 600s
  mailpit:
    image: axllent/mailpit:v1.31.5
    container_name: lab-book-ea-mailpit
    restart: unless-stopped
    ports:
      - "127.0.0.1:28411:8025"
volumes:
  db:
```

ثم ننشئ ملف `.env` بكلمتي مرور عشوائيتين ونشغل:

```bash
umask 077
printf "DB_PASSWORD=%s\nDB_ROOT_PASSWORD=%s\n" $(openssl rand -hex 16) $(openssl rand -hex 16) > .env
docker compose up -d
```

في الإعداد أعلاه لاحظ التالي:

- `BASE_URL` هو العنوان الذي تبنى منه كل الروابط، بما فيها رابط تعديل الموعد في بريد العميل، فاكتب فيه عنوان HTTPS العام في الإنتاج، ودون شرطة مائلة في آخره، لأن صفحة التثبيت ترفض العمل إذا وجدتها.
- المنافذ مربوطة بالعنوان 127.0.0.1، والوصول من الخارج يكون عبر ال Reverse Proxy فقط.
- السطر `start_period: 600s` لم يكن في ملفنا الأول، والقصة في الفقرة التالية.

### أول تشغيل: قاعدة بيانات بلا مستخدم

على جهاز التجربة، والقرص مشغول بتجارب أخرى، استغرقت تهيئة MySQL أكثر من ثلاث دقائق، ففشل فحص الصحة Healthcheck بعد 30 محاولة، وتوقف Compose بهذا السطر:

```text
dependency failed to start: container lab-book-ea-db is unhealthy
```

فأعدنا الأمر `docker compose up -d`، فأعاد Compose إنشاء ال Container والتهيئة لم تكتمل بعد، وبدأ التطبيق يرد بالخطأ 500، وفي سجله `storage/logs` هذا السطر:

```text
ERROR - 2026-10-11 17:24:53 --> Severity: error --> Exception: Host '192.168.80.4' is not allowed to connect to this MySQL server
```

والسبب أن سكربت التهيئة في MySQL ينشئ المستخدم `ea` ويضبط كلمة مرور root في آخر خطوة، فلما انقطع قبلها بقيت القاعدة دون المستخدم، وبكلمة مرور فارغة للمستخدم root. والحل الصحيح أن تحذف ال Volume وتبدأ من جديد بالأمر `docker compose down -v`، وأن تعطي القاعدة مهلة كافية في أول تشغيل بالخاصية `start_period`، والسبب أن الفحوص الفاشلة داخل هذه المهلة لا تحسب. ولا تقم بترك قاعدة نصف مهيأة في الإنتاج، فقد رأيت أن كلمة مرور root فيها فارغة.

### صفحة التثبيت والملاحظة على Xdebug

بعد التشغيل يحولك العنوان الرئيسي إلى صفحة التثبيت `/index.php/installation`، فتدخل بيانات المدير واسم الجهة وبريدها ورابط موقعها، والصورة التالية تبينها، ولاحظ أن نصوصها نصف عربية ونصف إنجليزية، وأن الحقول تبقى من اليسار إلى اليمين:

![صفحة تثبيت Easy!Appointments وفيها شرح بالإنجليزية وحقول بيانات المدير والشركة بعناوين عربية، والتخطيط من اليسار إلى اليمين](https://arabroot.io/content/images/2026/10/easy-appointments-02-install.webp)

صفحة التثبيت: عناوين الحقول بالعربية، والشرح بالإنجليزية

وينشئ المثبت بيانات تجريبية: خدمة باسم Service، ومقدم خدمة باسم Jane Doe، وعميلاً باسم James، فاحذفها قبل الاستخدام الفعلي. وهناك ملاحظة على ال Docker Image الرسمي: [ملف Dockerfile](https://github.com/alextselegidis/easyappointments-docker/blob/main/Dockerfile?ref=arabroot.io) يثبت إضافة Xdebug ويفعلها، وملف `99-overrides.ini` يضبطها على وضع التصحيح، وقد تأكدنا من ذلك داخل ال Container:

```bash
docker exec lab-book-ea-app sh -c 'php -m | grep -i xdebug; php -i | grep "xdebug.mode"'
```

```text
xdebug
Xdebug
             Enabled Features (through 'xdebug.mode' setting)
xdebug.mode => debug => debug
```

و Xdebug أداة تطوير تبطئ PHP، ولا مكان لها على سيرفر إنتاج، فإذا أردت أن تستخدم هذه ال Image فابن واحدة فوقها تعطل الإضافة، أو ثبت التطبيق من ملف الإصدار على PHP وApache بنفسك.

## الخدمات ومقدموها وجداول العمل

أدخلنا بيانات العيادة التجريبية عبر ال REST API لأنها أسرع وتتكرر بالأوامر نفسها، والنتيجة مثل الإدخال من لوحة الإدارة: تصنيف «العيادة العامة»، وخدمتان هما «كشف عام» 30 دقيقة ب 150 ريالاً و«تنظيف أسنان» 45 دقيقة، وطبيبان هما سارة الأحمد وخالد العمر، وموظفة استقبال تدير مواعيدهما. ولكل طبيب جدول عمل من الأحد إلى الخميس من 9:00 إلى 17:00 مع استراحة من 13:00 إلى 13:30، والجمعة والسبت عطلة. والصورة التالية تبين جدول عمل الطبيبة سارة في لوحة الإدارة:

![تبويب خطة العمل لمقدم الخدمة في Easy!Appointments، فيه أيام الأحد إلى الخميس من 9:00 صباحاً إلى 5:00 مساء، والجمعة والسبت دون ساعات، وتحتها قسم الاستراحات](https://arabroot.io/content/images/2026/10/easy-appointments-03-working-plan.webp)

جدول العمل لكل مقدم خدمة، والاستراحات تحته

ثم أضفنا إجازة للطبيبة سارة يوم الخميس 15 أكتوبر كفترة «غير متوفر»، وأغلقنا العيادة كلها يوم 22 أكتوبر بفترة محجوبة. والآن نسأل ال API عن الأوقات المتاحة لخدمة الكشف مع الطبيبة سارة في أربعة أيام:

```bash
A='clinicadmin:********'
U=http://127.0.0.1:28410/index.php/api/v1
for d in 2026-10-13 2026-10-15 2026-10-16 2026-10-22; do
  echo "$d: $(curl -s -u "$A" "$U/availabilities?providerId=4&serviceId=2&date=$d")"
done
```

```text
2026-10-13: ["09:00","09:30","10:00","10:30","11:00","11:30","12:00","12:30","13:30","14:00","14:30","15:00","15:30","16:00","16:30"]
2026-10-15: []
2026-10-16: []
2026-10-22: []
```

في المخرج أعلاه لاحظ التالي:

- يوم الثلاثاء 13 أكتوبر فيه 15 وقتاً، ووقت 13:00 غير موجود لأنه داخل الاستراحة، وآخر وقت 16:30 لأن الكشف 30 دقيقة وينتهي الدوام في 17:00.
- يوم 15 فارغ بسبب الإجازة، ويوم 16 جمعة، ويوم 22 محجوب للعيادة كلها.

وفي ال API نقطة يجب أن تعرفها: ملف [openapi.yml](https://github.com/alextselegidis/easyappointments/blob/1.6.0/openapi.yml?ref=arabroot.io) في الإصدار 1.6.0 يذكر المسار `/working_plan_exceptions`، ولكن ملف المسارات `routes.php` في الإصدار نفسه لا يحتوي عليه، فرد علينا السيرفر بالخطأ 404، لذلك أضف استثناءات خطة العمل من لوحة الإدارة، من زر «استثناء خطة العمل» في صفحة التقويم.

## صفحة الحجز كما يراها العميل

صفحة الحجز هي العنوان الرئيسي نفسه، وهي أربع خطوات: الخدمة ومقدمها، ثم التاريخ والوقت، ثم بيانات العميل، ثم التأكيد. والصورتان التاليتان تبينان الخطوتين الأوليين كما ظهرتا لنا بالعربية:

![الخطوة الأولى في صفحة الحجز: عنوان اختر الخدمة والمزود، وقائمتان للخدمة ومقدمها، وزر التالي في اليمين بسهم يشير إلى اليمين](https://arabroot.io/content/images/2026/10/booking-tools-02-easy-appointments.webp)

الخطوة الأولى: الخدمة ومقدمها، والترجمة تسمي مقدم الخدمة «المزود»

![الخطوة الثانية في صفحة الحجز: تقويم أكتوبر فيه الأيام المتاحة، وقائمة المنطقة الزمنية على Kuwait، والأوقات المتاحة من 9:00 صباحاً](https://arabroot.io/content/images/2026/10/easy-appointments-06-booking-step2.webp)

الخطوة الثانية: الأيام والأوقات المتاحة بحسب جدول العمل والإجازات

ولاحظ في الصورة الثانية أن أيام الإجازة والعطلة واليوم المحجوب رمادية لا تختار، وأن المنطقة الزمنية هنا Kuwait، وسوف نرى في [تجربة المنطقة الزمنية](#break-timezone) لماذا اخترناها يدوياً. وبعد التأكيد يحفظ الموعد، ويظهر في تقويم الطبيبة في لوحة الإدارة، وتخرج أربع رسائل بريد كما في القسم التالي.

### هل تضع صفحة الحجز داخل موقعك؟

قد تريد أن تظهر صفحة الحجز داخل موقع العيادة في iframe، ولكن التطبيق يضيف إلى كل رد الترويسة `X-Frame-Options: SAMEORIGIN` من الملف `application/hooks/security_headers.php`، لحماية لوحة الإدارة من هجمات Clickjacking، وهذا ما رأيناه في الرد:

```bash
curl -s -D - -o /dev/null http://127.0.0.1:28410/ | grep -i x-frame
```

```text
X-Frame-Options: SAMEORIGIN
```

والنتيجة أن المتصفح يرفض عرض الصفحة داخل موقع من أصل Origin آخر، كما في الصورة التالية من صفحة تجريبية فيها iframe:

![صفحة باسم موقع العيادة فيها إطار iframe فارغ رمادي بأيقونة خطأ، لأن المتصفح رفض عرض صفحة الحجز داخل أصل مختلف](https://arabroot.io/content/images/2026/10/easy-appointments-13-embed-blocked.webp)

صفحة الحجز داخل iframe على أصل آخر: المتصفح يرفض عرضها

لذلك الحل الأبسط أن تضع في موقعك زر «احجز موعداً» يفتح صفحة الحجز على نطاقها الفرعي، مثل `book.clinic.example.com`، وإذا كان iframe ضرورياً فعليك أن تحذف هذه الترويسة في ال Reverse Proxy لمسارات صفحة الحجز فقط، وتضع بدلها `Content-Security-Policy: frame-ancestors https://clinic.example.com`، وتبقيها كما هي للوحة الإدارة، ولم نختبر هذا الخيار في تجربتنا.

## رسائل البريد مع Mailpit

وجهنا البريد إلى Mailpit، وهو سيرفر SMTP للتجارب يلتقط الرسائل ويعرضها في المتصفح دون أن يرسلها، وبعد حجز واحد وصلت أربع رسائل، نقرؤها من ال API الخاصة بMailpit:

```bash
curl -s http://127.0.0.1:28411/api/v1/messages | python3 -c "
import json, sys
for m in json.load(sys.stdin)['messages']: print(m['To'][0]['Address'], ' ', m['Subject'])"
```

```text
reception@clinic.example.com  تم اضافة موعد الى خطتك
admin@clinic.example.com  تم اضافة موعد الى خطتك
sara@clinic.example.com  تم اضافة موعد الى خطتك
fahad@example.com  !تم حجز الموعد بنجاح
```

أي رسالة للعميل، ورسالة لكل من مقدم الخدمة والمدير والسكرتارية التي تدير مواعيده، ويمكنك إيقاف رسائل أي مستخدم من خيار «تلقي الإشعارات» في حسابه. والصورة التالية تبين رسالة العميل:

![رسالة تأكيد الحجز في Mailpit بعنوان تم حجز الموعد بنجاح، فيها تفاصيل الموعد والعميل بالعربية، وكلمة Booked للحالة، وزر Reschedule / Cancel Appointment بالإنجليزية](https://arabroot.io/content/images/2026/10/easy-appointments-09-mail-customer.webp)

رسالة العميل: التفاصيل بالعربية، والحالة وزر التعديل بالإنجليزية

ومع الرسالة مرفق باسم `invitation.ics`، وهو موعد بصيغة iCalendar يضيفه العميل إلى تقويم جواله بضغطة، وقد شرحنا هذه الصيغة في [مقال CalDAV وCardDAV](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D9%82%D9%88%D9%8A%D9%85-%D9%88%D8%AC%D9%87%D8%A7%D8%AA-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84-caldav-%D9%88-carddav/#icalendar-vcard)، وهذا جزء منه:

```text
BEGIN:VTIMEZONE
TZID:Asia/Riyadh
...
DTSTART:20261013T070000Z
DTEND:20261013T073000Z
STATUS:CONFIRMED
SUMMARY:كشف عام
```

ولاحظ أن الوقت في المرفق بتوقيت UTC، فالساعة 07:00 بتوقيت UTC هي 10:00 في الرياض، وهو وقت الموعد الصحيح. وفي الإنتاج اضبط SMTP على خدمة إرسال حقيقية، واقرأ [مقال Transactional Email](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%AA%D9%81%D8%A7%D8%B9%D9%84%D9%8A-transactional-email/) قبل ذلك، لأن رسالة تأكيد تصل إلى البريد المزعج تعني موعداً يفوت.

## المزامنة مع Google Calendar ومع CalDAV

يدعم الإصدار 1.6.0 نوعين من المزامنة لكل مقدم خدمة على حدة، وكلاهما في الاتجاهين بحسب التوثيق:

- **Google Calendar:** بحسب [توثيق المزامنة مع Google](https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/google-calendar-sync.md?ref=arabroot.io) تحتاج مشروعاً في Google Cloud، وتفعيل Google Calendar API، وعميل OAuth بعنوان رجوع `/index.php/google/oauth_callback` على نطاقك، ثم يربط كل مقدم خدمة حساباً واحداً. ولم نختبره في تجربتنا لأنه يحتاج نطاقاً عاماً وحساب Google.
- **CalDAV:** بحسب [توثيق المزامنة مع CalDAV](https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/caldav-calendar-sync.md?ref=arabroot.io) تدخل عنوان التقويم واسم المستخدم وكلمة المرور، وقد اختبرناه مع Radicale الذي شرحناه في [دليل Radicale](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-radicale-%D9%84%D9%84%D8%AA%D9%82%D9%88%D9%8A%D9%85-%D9%88%D8%AC%D9%87%D8%A7%D8%AA-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84/).

أضفنا Radicale 3.8.3 إلى ملف Compose، وأنشأنا تقويماً للطبيبة سارة بالطلب `MKCALENDAR`، ثم ضغطنا في صفحة التقويم «تمكين المزامنة» وأدخلنا العنوان `http://radicale:5232/sara/clinic/`، فظهر هذا الخطأ:

![نافذة خادم CalDAV في Easy!Appointments وفيها عنوان التقويم واسم المستخدم وكلمة المرور، وتحتها رسالة خطأ تقول تعذر تسجيل الدخول وفشلت المزامنة مع جوجل](https://arabroot.io/content/images/2026/10/easy-appointments-14-caldav-blocked.webp)

خطأ الاتصال بسيرفر CalDAV داخلي، والرسالة تذكر Google خطأ

والرسالة مضللة، فكلمة المرور صحيحة، وسيرفر Radicale لم يستقبل أي طلب، والسبب الحقيقي في سجل التطبيق:

```text
ERROR - 2026-10-12 00:06:02 --> CalDAV - Connection test failed: CalDAV URL host is not allowed.
```

وعند قراءة الملف `application/libraries/Caldav_sync.php` وجدنا الدالة `assert_safe_caldav_url`، وهي حماية من هجمات SSRF، حيث يحل التطبيق اسم السيرفر إلى عناوين IP، ويرفض أي عنوان في الشبكات الخاصة Private Ranges أو المحجوزة، والاستثناء الوحيد سيرفر اسمه بالضبط `baikal` عبر http، وهو اسم سيرفر CalDAV في بيئة التطوير الخاصة بالمشروع. وهذا يعني أن سيرفر CalDAV على شبكتك الداخلية أو على شبكة Docker نفسها لن يقبل، وأن الطريقة الطبيعية أن يكون سيرفر CalDAV على نطاق يحل إلى عنوان عام، مثل `https://dav.example.com` خلف ال Reverse Proxy.

ولنكمل التجربة أضفنا إلى Radicale اسماً بديلاً Alias هو `baikal` في شبكة Compose، واستخدمنا العنوان `http://baikal:5232/sara/clinic/`، فنجح الاتصال، ولا ننصح بذلك في الإنتاج، والسبب أنك تعتمد على استثناء وضع لبيئة التطوير وقد يحذف في أي إصدار. وبعد ضغط زر «المزامنة» أرسل التطبيق مواعيد الطبيبة إلى Radicale، ثم أضفنا من جهة Radicale موعداً اسمه Staff meeting يوم 20 أكتوبر من 08:00 إلى 09:00 بتوقيت UTC، وبعد المزامنة التالية ظهر في Easy!Appointments كفترة «غير متوفر»:

```text
"start": "2026-10-20 11:00:00",
"end": "2026-10-20 12:00:00",
"notes": "Staff meeting ",
"caldavCalendarId": "lab-staff-meeting-1"
```

واختفى الوقتان 11:00 و11:30 من الأوقات المتاحة لذلك اليوم:

```text
["09:00","09:30","10:00","10:30","12:00","12:30","13:30","14:00","14:30","15:00","15:30","16:00","16:30"]
```

أي أن المزامنة تعمل في الاتجاهين: الموعد الذي يحجزه العميل يصل إلى تقويم الطبيبة على جوالها، والاجتماع الذي تضيفه في جوالها يغلق ذلك الوقت في صفحة الحجز، ولكن بعد المزامنة وليس في اللحظة نفسها.

## الواجهة العربية بصراحة

ملف الترجمة العربية في الإصدار 1.6.0 فيه 572 نصاً، مثل الملف الإنجليزي تماماً، فالترجمة مكتملة من حيث العدد، وقد رأيت النتيجة في الصور السابقة، وهذا ما وجدناه في التجربة:

- **لا يوجد دعم RTL:** الصفحة تحمل `lang="ar"` دون `dir="rtl"`، فالحقول والعناوين محاذاة لليسار، وسهم زر «التالي» وسهم زر «الخلف» يشيران إلى اليمين معاً، وأيام الأسبوع في تقويم صفحة الحجز تبدأ من اليسار. والطلب مفتوح منذ 2016 في [التذكرة 110](https://github.com/alextselegidis/easyappointments/issues/110?ref=arabroot.io).
- **مصطلحات تحتاج مراجعة:** «المزود» بدل «مقدم الخدمة» أو «الطبيب»، و«الزبون» بدل «العميل» أو «المراجع».
- **نصوص لم تترجم:** شرح صفحة التثبيت، وحالة الموعد Booked، وزر Reschedule / Cancel Appointment في البريد، وصيغة الوقت am وpm.
- **المنطقة الزمنية Asia/Riyadh غير موجودة:** قائمة المناطق الزمنية في الملف `application/libraries/Timezones.php` فيها Kuwait وQatar وBahrain، وليس فيها Riyadh، لا في الإصدار 1.6.0 ولا في الفرع الرئيسي عند كتابة المقال، وسوف نرى أثر ذلك في التجربة الثانية.

وبعض هذه النقاط تستطيع إصلاحها بنفسك، فالنصوص في `application/language/arabic/translations_lang.php`، ولكن تعديل ملف داخل ال Container يضيع مع كل تحديث، فالأفضل أن تساهم بالتعديل في المشروع نفسه.

## ال REST API وال Webhooks

ال API تحت المسار `/index.php/api/v1`، وتقبل بحسب [توثيق ال API](https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/rest-api.md?ref=arabroot.io) طريقتين للمصادقة: اسم مستخدم وكلمة مرور مدير بال Basic Auth، أو مفتاح API تنشئه من الإعدادات وترسله Bearer Token، وفيها كل شيء تقريباً: المواعيد والعملاء والخدمات ومقدموها والإعدادات والأوقات المتاحة.

وال Webhooks ترسل طلب POST إلى نظامك عند كل تغيير، مثل نظام CRM أو نظام الفواتير. أنشأنا Container صغيراً يطبع ما يصله، ثم سجلنا Webhook من ال API:

```json
{
  "name": "crm",
  "url": "http://hook:8080/ea",
  "actions": "appointment_save,appointment_delete",
  "secretToken": "lab-webhook-secret",
  "isSslVerified": false
}
```

```bash
curl -s -u "$A" -H 'Content-Type: application/json' --data-binary @webhook.json "$U/webhooks"
```

```text
{"id":1,"name":"crm","url":"http:\/\/hook:8080\/ea","actions":"appointment_save,appointment_delete","secretToken":"lab-webhook-secret","isSslVerified":false,"notes":null}
```

وبعد حجز موعد من صفحة الحجز وصل هذا الطلب:

```text
POST /ea
User-Agent: GuzzleHttp/7
Content-Type: application/json
X-Ea-Token: lab-webhook-secret
{
  "action": "appointment_save",
  "payload": {
    "id": 18,
    "start_datetime": "2026-10-21 15:00:00",
    "end_datetime": "2026-10-21 15:30:00",
    "hash": "H5dvFePkAztJ",
    "status": "Booked",
    "is_unavailability": false,
    "id_users_provider": 4,
    "id_users_customer": 9,
    "id_services": 2,
    ...
  }
}
```

في المخرج أعلاه لاحظ التالي:

- أسماء الأحداث في الكود هي `appointment_save` و`appointment_delete` وأمثالها في الملف `application/config/constants.php`، بينما يكتب مثال ملف openapi.yml `appointment_create`، وهذا الاسم لا يطلق أي طلب، فاعتمد على أسماء الكود.
- السر يصل كما هو في الترويسة `X-Ea-Token`، وليس توقيعاً HMAC لمحتوى الطلب، فتحقق منه في نظامك، واستخدم HTTPS حتى لا يقرأه أحد في الطريق.
- الوقت دون منطقة زمنية، وهو بتوقيت مقدم الخدمة، فيجب أن يعرف نظامك هذه المنطقة ليحول الوقت بشكل صحيح.

## اكسرها: عشرة عملاء على الموعد نفسه

في الاستخدام العادي لا يرى العميل الوقت المحجوز، وإذا كانت صفحته مفتوحة قبل الحجز ثم حاول حجز الوقت نفسه، فإن السيرفر يتحقق ويرفض، وهذا ما رأيناه عند إرسال حجز ثان لموعد الساعة 10:00 يوم 13 أكتوبر:

```text
0 500 {"success": false, "message": "الموعد الذي طلبته غير متاح للأسف . يرجى اختيار ساعة مختلفة لحجز الموعد "}
```

ولكن ماذا يحدث إذا وصل الطلبان في اللحظة نفسها؟ في الدالة `register` في [Booking.php](https://github.com/alextselegidis/easyappointments/blob/1.6.0/application/controllers/Booking.php?ref=arabroot.io) يتحقق الكود من أن الوقت متاح، ثم يحفظ العميل والموعد، دون قفل ودون معاملة Transaction تجمع التحقق مع الحفظ، وجدول المواعيد ليس فيه قيد فريد Unique Constraint على مقدم الخدمة ووقت البداية. وهذه هي الحالة التي شرحناها بالتفصيل في [مقال نظام حجز التذاكر](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%A8%D9%86%D9%8A-%D9%86%D8%B8%D8%A7%D9%85-%D8%AD%D8%AC%D8%B2-%D8%AA%D8%B0%D8%A7%D9%83%D8%B1-%D9%8A%D8%AA%D8%AD%D9%85%D9%84-%D9%84%D8%AD%D8%B8%D8%A9-%D9%81%D8%AA%D8%AD-%D8%A7%D9%84%D8%A8%D9%8A%D8%B9/#double-booking). فكتبنا سكربت Python يفتح صفحة الحجز في عشر جلسات منفصلة، كل جلسة بالرمز CSRF الخاص بها، ثم يرسل الطلبات العشرة إلى `/index.php/booking/register` في اللحظة نفسها لموعد الساعة 11:00 يوم 14 أكتوبر مع الطبيبة سارة:

والسكربت كاملاً كما يلي، وهو يستخدم مكتبة Python القياسية فقط:

```python
# race.py: N customers try to book the same slot with the same provider at the same moment
import re, sys, json, threading, urllib.request, urllib.parse, http.cookiejar
BASE = 'http://127.0.0.1:28410'
N = int(sys.argv[1]); START, END = sys.argv[2], sys.argv[3]

def flatten(prefix, obj, out):
    for k, v in obj.items():
        key = f'{prefix}[{k}]'
        if isinstance(v, dict): flatten(key, v, out)
        else: out.append((key, 'false' if v is False else str(v)))

def worker(i, barrier, results):
    jar = http.cookiejar.CookieJar()
    op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
    html = op.open(BASE + '/').read().decode('utf-8')                 # own session and CSRF cookie
    token = re.search(r'"csrf_token":"([0-9a-f]+)"', html).group(1)
    post = {'customer': {'first_name': f'عميل{i}', 'last_name': 'تجربة', 'email': f'race{i}@example.com',
                         'phone_number': f'+96650000{i:04d}', 'address': '', 'city': '', 'zip_code': '', 'timezone': 'Asia/Kuwait'},
            'appointment': {'start_datetime': START, 'end_datetime': END, 'notes': '', 'is_unavailability': False,
                            'id_users_provider': 4, 'id_services': 2},
            'manage_mode': False}
    form = [('csrf_token', token)]; flatten('post_data', post, form)
    data = urllib.parse.urlencode(form).encode()
    barrier.wait()                                                    # all requests leave together
    try:
        r = op.open(urllib.request.Request(BASE + '/index.php/booking/register', data=data,
                                           headers={'X-Requested-With': 'XMLHttpRequest'}))
        results[i] = (r.status, r.read().decode('utf-8'))
    except urllib.error.HTTPError as e:
        results[i] = (e.code, e.read().decode('utf-8'))

barrier = threading.Barrier(N); results = {}
ts = [threading.Thread(target=worker, args=(i, barrier, results)) for i in range(N)]
[t.start() for t in ts]; [t.join() for t in ts]
for i in sorted(results): print(i, results[i][0], json.dumps(json.loads(results[i][1]), ensure_ascii=False))
print('accepted:', sum(1 for s, b in results.values() if s == 200 and 'appointment_id' in b))
```

والتشغيل لعشرة عملاء:

```bash
python3 race.py 10 "2026-10-14 11:00:00" "2026-10-14 11:30:00"
```

```text
0 200 {"appointment_id": 5, "appointment_hash": "VWsqHyEFtbRx"}
1 500 {"success": false, "message": "الموعد الذي طلبته غير متاح للأسف . يرجى اختيار ساعة مختلفة لحجز الموعد "}
2 200 {"appointment_id": 6, "appointment_hash": "Jl5cbvamTPGC"}
3 200 {"appointment_id": 3, "appointment_hash": "72R0dWQ8USyg"}
4 200 {"appointment_id": 8, "appointment_hash": "GuMx0mowbJdF"}
5 200 {"appointment_id": 9, "appointment_hash": "cUTNufREitDC"}
6 200 {"appointment_id": 4, "appointment_hash": "DhfZ4FoEyzBQ"}
7 500 {"success": false, "message": "الموعد الذي طلبته غير متاح للأسف . يرجى اختيار ساعة مختلفة لحجز الموعد "}
8 200 {"appointment_id": 7, "appointment_hash": "c1CeDAIK3OYP"}
9 500 {"success": false, "message": "الموعد الذي طلبته غير متاح للأسف . يرجى اختيار ساعة مختلفة لحجز الموعد "}
accepted: 7
```

سبعة عملاء حصلوا على تأكيد للموعد نفسه، وتأكدنا من قاعدة البيانات:

```sql
SELECT a.id, a.start_datetime, a.id_users_provider AS provider, c.email
FROM ea_appointments a JOIN ea_users c ON c.id = a.id_users_customer
WHERE a.start_datetime = '2026-10-14 11:00:00' ORDER BY a.id;
```

```text
id	start_datetime	provider	email
3	2026-10-14 11:00:00	4	race3@example.com
4	2026-10-14 11:00:00	4	race6@example.com
5	2026-10-14 11:00:00	4	race0@example.com
6	2026-10-14 11:00:00	4	race2@example.com
7	2026-10-14 11:00:00	4	race8@example.com
8	2026-10-14 11:00:00	4	race4@example.com
9	2026-10-14 11:00:00	4	race5@example.com
```

والصورة التالية تبين تقويم الطبيبة في لوحة الإدارة، ولاحظ المواعيد السبعة المتراصة يوم الأربعاء الساعة 11:00، والإجازة يوم الخميس:

![تقويم الأسبوع في لوحة إدارة Easy!Appointments للطبيبة سارة، فيه موعد واحد يوم الثلاثاء الساعة 10:00، وسبعة مواعيد متراصة يوم الأربعاء الساعة 11:00، ويوم الخميس كله غير متوفر، والجمعة والسبت رمادية](https://arabroot.io/content/images/2026/10/easy-appointments-10-double-booking.webp)

سبعة مواعيد مؤكدة في الوقت نفسه مع الطبيبة نفسها

وقد يتساءل البعض: هل يحدث هذا مع عميلين فقط؟ والإجابة نعم، فقد كررنا التجربة بطلبين متزامنين ثلاث مرات على ثلاثة أوقات مختلفة، فقبل الطلبان معاً في مرتين من الثلاث. ويجدر الإشارة هنا إلى أن سيرفر التجربة كان بطيئاً لأن قرصه مشغول بتجارب أخرى، والبطء يوسع النافذة بين التحقق والحفظ، فعلى سيرفر سريع ستكون الحالة أندر، ولكنها لا تختفي. وفي عيادة صغيرة يكون احتمال أن يضغط مريضان زر التأكيد للموعد نفسه في الجزء نفسه من الثانية صغيراً، ولكن في حملة تطعيم أو عرض مفتوح للجميع يصبح وارداً، فعليك أن تراجع تقويم اليوم التالي، أو أن تراقب بالاستعلام أعلاه المواعيد المكررة، وأن تعرف أن الحل الحقيقي في الكود: قفل أو قيد فريد كما في مقال حجز التذاكر.

## اكسرها: مريض في الرياض يرى الساعة 6 صباحاً

في البداية ضبطنا منطقة الطبيبين والإعداد الافتراضي على `Asia/Riyadh` عبر ال API، فقبلها السيرفر لأنها منطقة صحيحة في PHP، ثم فتحنا صفحة الحجز من متصفح منطقته Asia/Riyadh، وهي حالة أي مريض في المملكة. وصفحة الحجز تحاول أن تختار منطقة المتصفح تلقائياً من قائمتها، فإذا لم تجدها اختارت UTC، وبما أن Riyadh ليست في القائمة، ظهرت الأوقات هكذا:

![الخطوة الثانية في صفحة الحجز والمنطقة الزمنية UTC، وأول وقت متاح 6:00 صباحاً بدل 9:00](https://arabroot.io/content/images/2026/10/easy-appointments-11-tz-utc-slots.webp)

المنطقة الزمنية UTC تلقائياً، فأول موعد للعيادة التي تفتح 9:00 يظهر 6:00 صباحاً

فاختار المريض في التجربة «11:00 am» وهو يظن أنه الحادية عشرة صباحاً، والنتيجة كما في قاعدة البيانات ورسائل البريد:

```text
15 2026-10-19 14:00:00 2026-10-19 14:30:00 mona@example.com UTC
```

- **الموعد المحفوظ** الساعة 14:00 بتوقيت الطبيبة، أي الثانية ظهراً.
- **رسالة المريض** تقول «19/10/2026 11:00 am» والمنطقة الزمنية UTC، وأغلب الناس لا يقرؤون السطر الأخير.
- **رسالة الطبيبة** تقول «2:00 pm»، وحقل المنطقة الزمنية فيها فارغ، لأن Riyadh ليست في القائمة التي تعرض الأسماء.
- **مرفق invitation.ics** صحيح لأنه بتوقيت UTC، فإذا أضافه المريض إلى جواله ظهر الساعة 2:00 مساء، وهذا يناقض نص الرسالة.

![رسالة تأكيد الحجز للمريضة منى في Mailpit، فيها البداية 19/10/2026 11:00 am والمنطقة الزمنية UTC](https://arabroot.io/content/images/2026/10/easy-appointments-12-tz-mail-customer.webp)

رسالة المريض: 11:00 بتوقيت UTC، والطبيبة تنتظره الساعة 2:00 ظهراً

وفي لوحة الإدارة أثر آخر، فحقل المنطقة الزمنية في صفحة الطبيب يظهر فارغاً، وعندما تعدل أي شيء في بياناته وتضغط «حفظ» يرفض النموذج الحفظ لأن الحقل مطلوب:

![نموذج بيانات مقدم الخدمة في لوحة الإدارة، وحقل المنطقة الزمنية فارغ ومحاط بالأحمر، ورسالة الحقول بنجمة مطلوب تعبئتها](https://arabroot.io/content/images/2026/10/easy-appointments-04-provider-timezone.webp)

المنطقة Asia/Riyadh ليست في القائمة، فيظهر الحقل فارغاً ويرفض الحفظ

والحل الذي اعتمدناه أن نضبط منطقة كل مقدم خدمة وكل سكرتارية والإعداد `default_timezone` على `Asia/Kuwait`، لأنها UTC+3 مثل الرياض ودون توقيت صيفي في البلدين، وأن نضع في صفحة الحجز أو في موقع العيادة جملة واضحة تقول إن المواعيد بتوقيت مكة المكرمة، لأن مريضاً متصفحه على Riyadh سوف يبقى يرى UTC حتى يغير القائمة بنفسه. وإذا أردت أن تصلح ذلك نهائياً فأضف `'Asia/Riyadh' => 'Riyadh (+3:00)'` إلى ملف `Timezones.php` في نسختك، أو ساهم بالتعديل في المشروع. وقد شرحنا القاعدة العامة للتعامل مع المناطق الزمنية في [مقال التاريخ والوقت](https://arabroot.io/articles/%D8%A7%D9%84%D8%AA%D8%A7%D8%B1%D9%8A%D8%AE-%D9%88%D8%A7%D9%84%D9%88%D9%82%D8%AA-utc-%D9%88%D8%A7%D9%84%D9%85%D9%86%D8%A7%D8%B7%D9%82-%D8%A7%D9%84%D8%B2%D9%85%D9%86%D9%8A%D8%A9-%D9%88%D8%A7%D9%84%D8%AA%D9%82%D9%88%D9%8A%D9%85-%D8%A7%D9%84%D9%87%D8%AC%D8%B1%D9%8A/).

## النسخ الاحتياطي واختبار الاستعادة

كل بيانات Easy!Appointments في قاعدة MySQL، أي المواعيد والعملاء والإعدادات وبيانات مزامنة التقويم، وليس في ال Container ملفات تحتاج نسخها غير ملف `.env` وملف Compose، لذلك يكفي `mysqldump` مع معاملة واحدة متسقة:

```bash
docker exec lab-book-ea-db sh -c 'mysqldump --single-transaction --no-tablespaces -uea -p"$MYSQL_PASSWORD" easyappointments' > ea-backup.sql
ls -l ea-backup.sql
```

وكان حجم الملف 34361 بايت لعيادتنا التجريبية. ثم اختبرنا الاستعادة في قاعدة مؤقتة على السيرفر نفسه، وقارنا عدد المواعيد:

```bash
docker exec lab-book-ea-db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "CREATE DATABASE restore_test"'
docker exec -i lab-book-ea-db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" restore_test' < ea-backup.sql
docker exec lab-book-ea-db sh -c 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "SELECT COUNT(*) FROM restore_test.ea_appointments; SELECT COUNT(*) FROM easyappointments.ea_appointments; DROP DATABASE restore_test;"'
```

```text
COUNT(*)
18
COUNT(*)
18
```

وتذكر أن ملف النسخة فيه بيانات المرضى، وكلمة مرور CalDAV لكل مقدم خدمة كما سوف نرى في القسم التالي، فشفره قبل أن تنقله، واتبع [قاعدة 3-2-1](https://arabroot.io/articles/%D8%A7%D9%84%D9%86%D8%B3%D8%AE-%D8%A7%D9%84%D8%A7%D8%AD%D8%AA%D9%8A%D8%A7%D8%B7%D9%8A-%D8%A8%D9%82%D8%A7%D8%B9%D8%AF%D8%A9-3-2-1-%D9%88%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%A7%D9%84%D8%A7%D8%B3%D8%AA%D8%B9%D8%A7%D8%AF%D8%A9/).

## الحماية قبل الإنتاج

صفحة الحجز يجب أن تكون عامة، وأما لوحة الإدارة وال API فلا يحتاجها إلا فريقك، لذلك قسم المسارات في ال Reverse Proxy: اسمح للجميع بالعنوان الرئيسي `/` والمسارات `/index.php/booking` و`/index.php/booking_confirmation` و`/assets`، فهذه التي استخدمتها صفحة الحجز في تجربتنا، وضع كل ما سواها خلف [authentik وForward Auth](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%A3%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A8%D8%A7%D8%B3%D8%AA%D8%AE%D8%AF%D8%A7%D9%85-authentik-forward-auth/)، أو اجعل لوحة الإدارة متاحة من خلال VPN فقط كما في [دليل WireGuard](https://arabroot.io/articles/%D8%B4%D8%A8%D9%83%D8%A9-%D8%AE%D8%A7%D8%B5%D8%A9-%D9%85%D8%B9-wireguard-%D9%88-wg-easy/). وبعد ذلك اختبر صفحة الحجز كاملة حتى التأكيد، ورابط التعديل في البريد، لأن أي مسار نسيته في القائمة سوف يكسرها. وفي الحماية نقاط أخرى رأيناها في التجربة:

- **ال API تعيد الأسرار كما هي:** طلب `GET /providers/4` أعاد كلمة مرور CalDAV للطبيبة بنص واضح في الحقل `caldavPassword`، وطلب `GET /settings` يعيد `api_token` و`ldap_password`، لذلك أي مفتاح API أو حساب مدير يكشف كل هذه الأسرار، فلا تعطه لنظام لا يحتاجه، واستخدم في CalDAV حساباً خاصاً بالمزامنة لا كلمة المرور الشخصية.
- **أوقف Xdebug** كما ذكرنا في قسم التثبيت، واترك `DEBUG_MODE` على `FALSE`.
- **اضبط BASE\_URL على HTTPS:** التطبيق يضيف الترويسة HSTS فقط عندما يعرف أن الطلب جاء عبر HTTPS، من المتغير `HTTPS` أو من `X-Forwarded-Proto`، فتأكد أن ال Reverse Proxy يرسل هذه الترويسة.
- **فعل CAPTCHA** من الإعدادات، والإصدار 1.6.0 يدعم ALTCHA، والسبب أن صفحة الحجز العامة تنشئ عميلاً وموعداً لأي طلب، ورأيت في التجربة أن سكربتاً بسيطاً حجز عشرة مواعيد في ثانية.
- **مدة الاحتفاظ بالبيانات:** الإعداد `data_retention_days` يحذف بيانات العملاء القديمة، فاضبطه بحسب سياسة جهتك.
- **الدخول عبر LDAP** متاح للموظفين إذا كان لديك دليل مستخدمين، وتوثيقه في [ملف LDAP](https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/ldap.md?ref=arabroot.io).

## قبل التشغيل الفعلي

1. احذف البيانات التجريبية التي ينشئها المثبت.
2. اضبط المنطقة الزمنية لكل مقدم خدمة وسكرتارية والإعداد الافتراضي على Asia/Kuwait، واكتب توقيت المواعيد بوضوح للمرضى.
3. اضبط SMTP على خدمة إرسال حقيقية، وجرب حجزاً كاملاً حتى وصول البريد وفتح المرفق في جوال.
4. ضع لوحة الإدارة وال API خلف SSO أو VPN، وفعل CAPTCHA في صفحة الحجز.
5. جدول نسخة احتياطية يومية لقاعدة MySQL، واختبر الاستعادة.
6. راقب المواعيد المكررة بالاستعلام في قسم الحجز المكرر، أو راجع تقويم اليوم التالي كل مساء.
7. عند التحديث اقرأ [دليل التحديث](https://github.com/alextselegidis/easyappointments/blob/1.6.0/docs/update-guide.md?ref=arabroot.io)، وخذ نسخة قبل تغيير رقم الإصدار.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- Easy!Appointments 1.6.0 مبني حول الخدمة ومقدمها وجدول عمله، وهذا ما تحتاجه العيادة ومركز الخدمة، وكله GPL-3.0.
- أعط MySQL مهلة كافية في أول تشغيل، وإذا انقطعت التهيئة فاحذف ال Volume وابدأ من جديد، والصورة الرسمية تفعل Xdebug فعطله.
- صفحة الحجز لا تعمل داخل iframe على موقع آخر بسبب X-Frame-Options، فاستخدم رابطاً أو زراً.
- المزامنة مع CalDAV تعمل في الاتجاهين، ولكن حماية SSRF ترفض أي سيرفر CalDAV على عنوان خاص، فضعه على نطاق عام.
- الترجمة العربية مكتملة دون RTL، وAsia/Riyadh غير موجودة في قائمة المناطق، فاستخدم Asia/Kuwait.
- الحجز من صفحة الحجز يتحقق ثم يحفظ دون قفل، فقبل السيرفر سبعة عملاء من عشرة على الموعد نفسه في تجربتنا.

[![](https://arabroot.io/content/images/2026/10/booking-tools-cover-cover.webp)دليلأي أداة حجز مواعيد تناسب عيادتك أو فريقك؟أربع حالات حجز مختلفة، من العيادة إلى قاعة الاجتماعات، والأداة مفتوحة المصدر التي تناسب كل حالة منها.](https://arabroot.io/articles/%D8%A7%D9%8A-%D8%A7%D8%AF%D8%A7%D8%A9-%D8%AD%D8%AC%D8%B2-%D9%85%D9%88%D8%A7%D8%B9%D9%8A%D8%AF-%D8%AA%D9%86%D8%A7%D8%B3%D8%A8%D9%83/)[![](https://arabroot.io/content/images/2026/10/cal-com-cover-cover.webp)دليلCal.com على سيرفرك: بديل Calendly لحجز الاجتماعاتما الذي تستطيع استضافته من Cal.com بعد تحوله إلى الكود المغلق، وكيف تشغله وتربطه بتقويمك ونظامك.](https://arabroot.io/articles/%D8%AD%D8%AC%D8%B2-%D8%A7%D9%84%D9%85%D9%88%D8%A7%D8%B9%D9%8A%D8%AF-%D9%88%D8%A7%D9%84%D8%A7%D8%AC%D8%AA%D9%85%D8%A7%D8%B9%D8%A7%D8%AA-%D9%85%D8%B9-cal-com/)[![](https://arabroot.io/content/images/2026/10/radicale-cover-cover.webp)دليلRadicale: سيرفر تقويم وجهات اتصال خفيف لفريقككيف تشغل Radicale خلف Reverse Proxy بكلمات مرور bcrypt وتقويم مشترك للفريق، وتحفظ كل تغيير في git لتسترجعه عند الخطأ.](https://arabroot.io/articles/%D8%AA%D8%AB%D8%A8%D9%8A%D8%AA-radicale-%D9%84%D9%84%D8%AA%D9%82%D9%88%D9%8A%D9%85-%D9%88%D8%AC%D9%87%D8%A7%D8%AA-%D8%A7%D9%84%D8%A7%D8%AA%D8%B5%D8%A7%D9%84/)[![](https://arabroot.io/content/images/2026/10/authentik-forward-auth-cover-cover.webp)دليلكيف تحمي أي تطبيق ويب بصفحة دخول authentik عبر Forward Authكيف تضع صفحة دخول authentik مع المصادقة الثنائية أمام أي أداة داخلية دون أن تعدل في التطبيق نفسه.](https://arabroot.io/articles/%D8%AD%D9%85%D8%A7%D9%8A%D8%A9-%D8%A3%D9%8A-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82-%D8%A8%D8%A7%D8%B3%D8%AA%D8%AE%D8%AF%D8%A7%D9%85-authentik-forward-auth/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، والتجربة العملية على Easy!Appointments 1.6.0 مع MySQL 8.4.11 وMailpit v1.31.5 وRadicale 3.8.3 على Docker Engine 29.8.0.