الأدلة التقنية

Easy!Appointments: حجز مواعيد العيادات ومراكز الخدمة

كيف تشغل Easy!Appointments على سيرفرك لحجز الخدمات مع مقدميها، وما الذي يحتاج انتباهك في العربية والمنطقة الزمنية.

Easy!Appointments: حجز مواعيد العيادات ومراكز الخدمة

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

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

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

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

Easy!Appointments تطبيق PHP على إطار CodeIgniter مع قاعدة MySQL، وترخيصه GPL-3.0، واستخدمنا الإصدار المستقر 1.6.0. وقبل التثبيت عليك أن تعرف المفاهيم التي يبني عليها كل شيء، لأنك سوف تدخلها بهذا الترتيب:

  • الخدمة 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
صفحة الحجز للجميع، ولوحة الإدارة وال API وقاعدة البيانات خلف بوابتك

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

يوجد Docker Image رسمي من مستودع easyappointments-docker، يكتب ملف config.php وإعدادات البريد من المتغيرات عند كل تشغيل، ويحتاج قاعدة MySQL. وأما ملف docker-compose.yml الموجود في المستودع الرئيسي فهو للتطوير فقط بحسب توثيق Docker، فلا تقم باستخدامه في الإنتاج. وإذا لم يكن Docker مثبتاً لديك فقد شرحنا تثبيت Docker على Ubuntu. وهذا الملف الذي شغلناه، مع Mailpit لالتقاط البريد بدل إرساله:

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: "[email protected]"
      MAIL_FROM_NAME: "عيادة المثال"
      MAIL_REPLY_TO_ADDRESS: "[email protected]"
    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 بكلمتي مرور عشوائيتين ونشغل:

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 بهذا السطر:

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

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

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 وفيها شرح بالإنجليزية وحقول بيانات المدير والشركة بعناوين عربية، والتخطيط من اليسار إلى اليمين
صفحة التثبيت: عناوين الحقول بالعربية، والشرح بالإنجليزية

وينشئ المثبت بيانات تجريبية: خدمة باسم Service، ومقدم خدمة باسم Jane Doe، وعميلاً باسم James، فاحذفها قبل الاستخدام الفعلي. وهناك ملاحظة على ال Docker Image الرسمي: ملف Dockerfile يثبت إضافة Xdebug ويفعلها، وملف 99-overrides.ini يضبطها على وضع التصحيح، وقد تأكدنا من ذلك داخل ال Container:

docker exec lab-book-ea-app sh -c 'php -m | grep -i xdebug; php -i | grep "xdebug.mode"'
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 مساء، والجمعة والسبت دون ساعات، وتحتها قسم الاستراحات
جدول العمل لكل مقدم خدمة، والاستراحات تحته

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

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
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 في الإصدار 1.6.0 يذكر المسار /working_plan_exceptions، ولكن ملف المسارات routes.php في الإصدار نفسه لا يحتوي عليه، فرد علينا السيرفر بالخطأ 404، لذلك أضف استثناءات خطة العمل من لوحة الإدارة، من زر «استثناء خطة العمل» في صفحة التقويم.

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

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

الخطوة الأولى في صفحة الحجز: عنوان اختر الخدمة والمزود، وقائمتان للخدمة ومقدمها، وزر التالي في اليمين بسهم يشير إلى اليمين
الخطوة الأولى: الخدمة ومقدمها، والترجمة تسمي مقدم الخدمة «المزود»
الخطوة الثانية في صفحة الحجز: تقويم أكتوبر فيه الأيام المتاحة، وقائمة المنطقة الزمنية على Kuwait، والأوقات المتاحة من 9:00 صباحاً
الخطوة الثانية: الأيام والأوقات المتاحة بحسب جدول العمل والإجازات

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

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

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

curl -s -D - -o /dev/null http://127.0.0.1:28410/ | grep -i x-frame
X-Frame-Options: SAMEORIGIN

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

صفحة باسم موقع العيادة فيها إطار iframe فارغ رمادي بأيقونة خطأ، لأن المتصفح رفض عرض صفحة الحجز داخل أصل مختلف
صفحة الحجز داخل iframe على أصل آخر: المتصفح يرفض عرضها

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

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

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

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'])"
[email protected]  تم اضافة موعد الى خطتك
[email protected]  تم اضافة موعد الى خطتك
[email protected]  تم اضافة موعد الى خطتك
[email protected]  !تم حجز الموعد بنجاح

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

رسالة تأكيد الحجز في Mailpit بعنوان تم حجز الموعد بنجاح، فيها تفاصيل الموعد والعميل بالعربية، وكلمة Booked للحالة، وزر Reschedule / Cancel Appointment بالإنجليزية
رسالة العميل: التفاصيل بالعربية، والحالة وزر التعديل بالإنجليزية

ومع الرسالة مرفق باسم invitation.ics، وهو موعد بصيغة iCalendar يضيفه العميل إلى تقويم جواله بضغطة، وقد شرحنا هذه الصيغة في مقال CalDAV وCardDAV، وهذا جزء منه:

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

ولاحظ أن الوقت في المرفق بتوقيت UTC، فالساعة 07:00 بتوقيت UTC هي 10:00 في الرياض، وهو وقت الموعد الصحيح. وفي الإنتاج اضبط SMTP على خدمة إرسال حقيقية، واقرأ مقال Transactional Email قبل ذلك، لأن رسالة تأكيد تصل إلى البريد المزعج تعني موعداً يفوت.

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

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

  • Google Calendar: بحسب توثيق المزامنة مع Google تحتاج مشروعاً في Google Cloud، وتفعيل Google Calendar API، وعميل OAuth بعنوان رجوع /index.php/google/oauth_callback على نطاقك، ثم يربط كل مقدم خدمة حساباً واحداً. ولم نختبره في تجربتنا لأنه يحتاج نطاقاً عاماً وحساب Google.
  • CalDAV: بحسب توثيق المزامنة مع CalDAV تدخل عنوان التقويم واسم المستخدم وكلمة المرور، وقد اختبرناه مع Radicale الذي شرحناه في دليل Radicale.

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

نافذة خادم CalDAV في Easy!Appointments وفيها عنوان التقويم واسم المستخدم وكلمة المرور، وتحتها رسالة خطأ تقول تعذر تسجيل الدخول وفشلت المزامنة مع جوجل
خطأ الاتصال بسيرفر CalDAV داخلي، والرسالة تذكر Google خطأ

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

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 كفترة «غير متوفر»:

"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 من الأوقات المتاحة لذلك اليوم:

["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.
  • مصطلحات تحتاج مراجعة: «المزود» بدل «مقدم الخدمة» أو «الطبيب»، و«الزبون» بدل «العميل» أو «المراجع».
  • نصوص لم تترجم: شرح صفحة التثبيت، وحالة الموعد 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 طريقتين للمصادقة: اسم مستخدم وكلمة مرور مدير بال Basic Auth، أو مفتاح API تنشئه من الإعدادات وترسله Bearer Token، وفيها كل شيء تقريباً: المواعيد والعملاء والخدمات ومقدموها والإعدادات والأوقات المتاحة.

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

{
  "name": "crm",
  "url": "http://hook:8080/ea",
  "actions": "appointment_save,appointment_delete",
  "secretToken": "lab-webhook-secret",
  "isSslVerified": false
}
curl -s -u "$A" -H 'Content-Type: application/json' --data-binary @webhook.json "$U/webhooks"
{"id":1,"name":"crm","url":"http:\/\/hook:8080\/ea","actions":"appointment_save,appointment_delete","secretToken":"lab-webhook-secret","isSslVerified":false,"notes":null}

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

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 أكتوبر:

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

ولكن ماذا يحدث إذا وصل الطلبان في اللحظة نفسها؟ في الدالة register في Booking.php يتحقق الكود من أن الوقت متاح، ثم يحفظ العميل والموعد، دون قفل ودون معاملة Transaction تجمع التحقق مع الحفظ، وجدول المواعيد ليس فيه قيد فريد Unique Constraint على مقدم الخدمة ووقت البداية. وهذه هي الحالة التي شرحناها بالتفصيل في مقال نظام حجز التذاكر. فكتبنا سكربت Python يفتح صفحة الحجز في عشر جلسات منفصلة، كل جلسة بالرمز CSRF الخاص بها، ثم يرسل الطلبات العشرة إلى /index.php/booking/register في اللحظة نفسها لموعد الساعة 11:00 يوم 14 أكتوبر مع الطبيبة سارة:

والسكربت كاملاً كما يلي، وهو يستخدم مكتبة 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))

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

python3 race.py 10 "2026-10-14 11:00:00" "2026-10-14 11:30:00"
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

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

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;
id	start_datetime	provider	email
3	2026-10-14 11:00:00	4	[email protected]
4	2026-10-14 11:00:00	4	[email protected]
5	2026-10-14 11:00:00	4	[email protected]
6	2026-10-14 11:00:00	4	[email protected]
7	2026-10-14 11:00:00	4	[email protected]
8	2026-10-14 11:00:00	4	[email protected]
9	2026-10-14 11:00:00	4	[email protected]

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

تقويم الأسبوع في لوحة إدارة Easy!Appointments للطبيبة سارة، فيه موعد واحد يوم الثلاثاء الساعة 10:00، وسبعة مواعيد متراصة يوم الأربعاء الساعة 11:00، ويوم الخميس كله غير متوفر، والجمعة والسبت رمادية
سبعة مواعيد مؤكدة في الوقت نفسه مع الطبيبة نفسها

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

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

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

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

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

15 2026-10-19 14:00:00 2026-10-19 14:30:00 [email protected] 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
رسالة المريض: 11:00 بتوقيت UTC، والطبيبة تنتظره الساعة 2:00 ظهراً

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

نموذج بيانات مقدم الخدمة في لوحة الإدارة، وحقل المنطقة الزمنية فارغ ومحاط بالأحمر، ورسالة الحقول بنجمة مطلوب تعبئتها
المنطقة Asia/Riyadh ليست في القائمة، فيظهر الحقل فارغاً ويرفض الحفظ

والحل الذي اعتمدناه أن نضبط منطقة كل مقدم خدمة وكل سكرتارية والإعداد default_timezone على Asia/Kuwait، لأنها UTC+3 مثل الرياض ودون توقيت صيفي في البلدين، وأن نضع في صفحة الحجز أو في موقع العيادة جملة واضحة تقول إن المواعيد بتوقيت مكة المكرمة، لأن مريضاً متصفحه على Riyadh سوف يبقى يرى UTC حتى يغير القائمة بنفسه. وإذا أردت أن تصلح ذلك نهائياً فأضف 'Asia/Riyadh' => 'Riyadh (+3:00)' إلى ملف Timezones.php في نسختك، أو ساهم بالتعديل في المشروع. وقد شرحنا القاعدة العامة للتعامل مع المناطق الزمنية في مقال التاريخ والوقت.

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

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

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 بايت لعيادتنا التجريبية. ثم اختبرنا الاستعادة في قاعدة مؤقتة على السيرفر نفسه، وقارنا عدد المواعيد:

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;"'
COUNT(*)
18
COUNT(*)
18

وتذكر أن ملف النسخة فيه بيانات المرضى، وكلمة مرور CalDAV لكل مقدم خدمة كما سوف نرى في القسم التالي، فشفره قبل أن تنقله، واتبع قاعدة 3-2-1.

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

صفحة الحجز يجب أن تكون عامة، وأما لوحة الإدارة وال API فلا يحتاجها إلا فريقك، لذلك قسم المسارات في ال Reverse Proxy: اسمح للجميع بالعنوان الرئيسي / والمسارات /index.php/booking و/index.php/booking_confirmation و/assets، فهذه التي استخدمتها صفحة الحجز في تجربتنا، وضع كل ما سواها خلف authentik وForward Auth، أو اجعل لوحة الإدارة متاحة من خلال VPN فقط كما في دليل WireGuard. وبعد ذلك اختبر صفحة الحجز كاملة حتى التأكيد، ورابط التعديل في البريد، لأن أي مسار نسيته في القائمة سوف يكسرها. وفي الحماية نقاط أخرى رأيناها في التجربة:

  • ال 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.

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

  1. احذف البيانات التجريبية التي ينشئها المثبت.
  2. اضبط المنطقة الزمنية لكل مقدم خدمة وسكرتارية والإعداد الافتراضي على Asia/Kuwait، واكتب توقيت المواعيد بوضوح للمرضى.
  3. اضبط SMTP على خدمة إرسال حقيقية، وجرب حجزاً كاملاً حتى وصول البريد وفتح المرفق في جوال.
  4. ضع لوحة الإدارة وال API خلف SSO أو VPN، وفعل CAPTCHA في صفحة الحجز.
  5. جدول نسخة احتياطية يومية لقاعدة MySQL، واختبر الاستعادة.
  6. راقب المواعيد المكررة بالاستعلام في قسم الحجز المكرر، أو راجع تقويم اليوم التالي كل مساء.
  7. عند التحديث اقرأ دليل التحديث، وخذ نسخة قبل تغيير رقم الإصدار.

الخلاصة

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

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

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

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

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.