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

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

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

ثم أضفنا إجازة للطبيبة سارة يوم الخميس 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")"
done2026-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، وسوف نرى في تجربة المنطقة الزمنية لماذا اخترناها يدوياً. وبعد التأكيد يحفظ الموعد، ويظهر في تقويم الطبيبة في لوحة الإدارة، وتخرج أربع رسائل بريد كما في القسم التالي.
هل تضع صفحة الحجز داخل موقعك؟
قد تريد أن تظهر صفحة الحجز داخل موقع العيادة في 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-frameX-Frame-Options: SAMEORIGINوالنتيجة أن المتصفح يرفض عرض الصفحة داخل موقع من أصل Origin آخر، كما في الصورة التالية من صفحة تجريبية فيها 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] !تم حجز الموعد بنجاحأي رسالة للعميل، ورسالة لكل من مقدم الخدمة والمدير والسكرتارية التي تدير مواعيده، ويمكنك إيقاف رسائل أي مستخدم من خيار «تلقي الإشعارات» في حسابه. والصورة التالية تبين رسالة العميل:

ومع الرسالة مرفق باسم 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/، فظهر هذا الخطأ:

والرسالة مضللة، فكلمة المرور صحيحة، وسيرفر 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.ymlappointment_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، والإجازة يوم الخميس:

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

فاختار المريض في التجربة «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 مساء، وهذا يناقض نص الرسالة.

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

والحل الذي اعتمدناه أن نضبط منطقة كل مقدم خدمة وكل سكرتارية والإعداد 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.
قبل التشغيل الفعلي
- احذف البيانات التجريبية التي ينشئها المثبت.
- اضبط المنطقة الزمنية لكل مقدم خدمة وسكرتارية والإعداد الافتراضي على Asia/Kuwait، واكتب توقيت المواعيد بوضوح للمرضى.
- اضبط SMTP على خدمة إرسال حقيقية، وجرب حجزاً كاملاً حتى وصول البريد وفتح المرفق في جوال.
- ضع لوحة الإدارة وال API خلف SSO أو VPN، وفعل CAPTCHA في صفحة الحجز.
- جدول نسخة احتياطية يومية لقاعدة MySQL، واختبر الاستعادة.
- راقب المواعيد المكررة بالاستعلام في قسم الحجز المكرر، أو راجع تقويم اليوم التالي كل مساء.
- عند التحديث اقرأ دليل التحديث، وخذ نسخة قبل تغيير رقم الإصدار.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- 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.