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

Cal.com على سيرفرك: بديل Calendly لحجز الاجتماعات

ما الذي تستطيع استضافته من Cal.com بعد تحوله إلى الكود المغلق، وكيف تشغله وتربطه بتقويمك ونظامك.

Cal.com على سيرفرك: بديل Calendly لحجز الاجتماعات

ترسل لعميل أو مرشح لوظيفة رسالة تقول «متى يناسبك؟»، فيرد بوقتين لا يناسبانك، فترد بثلاثة أوقات أخرى، وبعد يومين من الرسائل تتفقان على موعد، وهذه هي المشكلة التي تحلها خدمة Calendly: رابط واحد يرى منه الطرف الآخر أوقاتك الفارغة فقط، ويختار منها، فيصل الموعد إلى تقويمك ومعه رابط الاجتماع. وفي دليل اختيار أداة الحجز قلنا إن Cal.com كان البديل المفتوح الأشهر لهذه الحالة، وإن شيئاً كبيراً تغير فيه في 2026. وفي هذا المقال من مرحلة المستندات والنماذج والتوقيع والحجز سوف نبدأ بما تغير بالضبط، وما الذي تستطيع استضافته منه اليوم، ثم نشغله على جهازنا ونبني نوع اجتماع وجدول توفر، ونربطه بتقويم CalDAV على Radicale وب Webhook، ونحاول أن نحجز الوقت نفسه من عشرة أشخاص في اللحظة نفسها.

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

  • ماذا حدث لكود Cal.com في أبريل 2026، والفرق بين Cal.com وCal.diy والترخيص كما في المستودع.
  • التثبيت بال Docker Compose بآخر Image رسمية، وكم يستغرق أول تشغيل.
  • الإعداد الأول واختيار الترخيص، ثم نوع الاجتماع Event Type وجدول التوفر Availability.
  • ربط تقويم CalDAV، ولماذا لا يكفي الربط وحده لمنع التعارض.
  • مسار الحجز كاملاً: البريد، والموعد في التقويم، وال Webhook وتوقيعه.
  • الواجهة العربية، والحجز المتزامن، وما الذي تغيره قبل الإنتاج.

ماذا حدث لكود Cal.com؟

حتى بداية 2026 كان كود Cal.com في المستودع العام calcom/cal.com، وملف LICENSE عند الإصدار v6.2.0 يقسمه كما يلي: المجلدان packages/features/ee وapps/api/v2/src/ee تحت ترخيص تجاري لا يسمح باستخدامهما في الإنتاج دون اشتراك Enterprise، ويسمح بنسخهما وتعديلهما للتطوير والاختبار، وكل ما سواهما تحت AGPLv3. ثم في 15 أبريل 2026 أعلنت الشركة أنها نقلت كود الخدمة إلى مستودع خاص، وأن المستودع العام أصبح باسم Cal.diy بترخيص MIT، بعد حذف ميزات المؤسسات منه، والصورة التالية تلخص ذلك:

مخطط من ثلاثة أجزاء: على اليمين مستودع cal.com حتى أبريل 2026 بنواة AGPLv3 ومجلد ee تجاري وآخر Image هي v6.2.0، وفي الوسط Cal.com اليوم مغلق المصدر في مستودع خاص، وعلى اليسار Cal.diy بترخيص MIT دون ميزات الفرق وسير العمل وSSO، ولا يوجد له أي وسم على Docker Hub
Cal.com أصبح مغلقاً، وCal.diy هو المستودع العام القديم بترخيص MIT دون ميزات المؤسسات

وبحسب إعلان الشركة فقد بقي في Cal.diy محرك الجدولة ومتجر التطبيقات App Store ومسارات الحجز وال API v2، وحذفت منه المنظمات والفرق، ونماذج التوجيه Routing Forms، وسير العمل Workflows، والحجز الفوري، والإحصاءات Insights، وSSO وSAML، وال API v1، وسجل تدقيق الحجوزات. ويقول ملف README في Cal.diy صراحة إن المشروع للاستخدام الشخصي وغير الإنتاجي، وإن من يريد بنية جدولة تجارية فعليه بنسخة Cal.com المستضافة، أو بعقد استضافة داخلية مع الشركة.

أين ال Docker Image؟

هنا المشكلة العملية التي سوف تواجهها في أول خطوة، فقد قرأنا سجل Docker Hub في أكتوبر 2026 فوجدنا ما يلي:

  • صفحة calcom/cal.diy على Docker Hub ليس فيها أي وسم Tag، مع أن ملف README يشير إليها.
  • آخر وسم في المستودع هو v6.2.0 بتاريخ 1 مارس 2026، أي قبل التحويل، ولم يصدر بعده أي إصدار.
  • آخر Image منشورة هي calcom/cal.com:v6.2.0 بتاريخ 2 مارس 2026، وهي من الكود قبل التحويل، أي AGPLv3 مع مجلد ee.
  • التذكرة 28995 عن غياب ال Image مفتوحة منذ أبريل، وفيها مستخدم يذكر في 30 سبتمبر أن آخر خمس محاولات لبناء ال Image في GitHub Actions فشلت، وأن بناءها من الكود استغرق نحو 36 دقيقة وبلغ استهلاك الذاكرة 6.5 GB على سيرفر بذاكرة 8 GB.

لذلك أمامك طريقان: أن تشغل calcom/cal.com:v6.2.0 كما فعلنا في هذا المقال، وهي Image رسمية جاهزة ولكنها لن تحصل على أي إصلاح أمني، أو أن تبني Cal.diy من الفرع الرئيسي بنفسك بحسب قسم Building from source with Docker، وتتحمل أنت اختيار الإصدار وبناءه واختباره عند كل تحديث. وقد يتساءل البعض: أيهما أفضل للإنتاج؟ والإجابة أن الاثنين ليسا مريحين، فالأول قديم ولن يتحدث، والثاني يقول عنه أصحابه إنه ليس للإنتاج، لذلك لا ننصح بوضع أي منهما على الإنترنت لعملائك دون أن تقرر من سوف يتابع تحديثاته.

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

ملف Compose في مستودع Cal.diy يبني ال Image من الكود ويستخدم postgres وredis:latest دون إصدار محدد، وينشر المنفذين 6379 و5555 على كل الواجهات، والثاني هو Prisma Studio الذي يفتح قاعدة البيانات كاملة في المتصفح، لذلك كتبنا ملفاً أصغر: PostgreSQL وتطبيق Cal.com، ومعهما Mailpit لالتقاط البريد، وRadicale 3.8.3 كسيرفر CalDAV، وContainer صغير يطبع ال Webhooks:

name: lab-book-cal
services:
  db:
    image: postgres:17.6-alpine
    container_name: lab-book-cal-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: calcom
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
      POSTGRES_DB: calcom
    volumes:
      - db:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U calcom"]
      interval: 5s
      retries: 60
      start_period: 300s
  calcom:
    image: calcom/cal.com:v6.2.0
    container_name: lab-book-cal-app
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:28440:3000"
    environment:
      DATABASE_URL: "postgresql://calcom:${POSTGRES_PASSWORD}@db:5432/calcom"
      DATABASE_DIRECT_URL: "postgresql://calcom:${POSTGRES_PASSWORD}@db:5432/calcom"
      NEXT_PUBLIC_WEBAPP_URL: "http://127.0.0.1:28440"
      NEXTAUTH_URL: "http://127.0.0.1:28440/api/auth"
      NEXTAUTH_SECRET: "${NEXTAUTH_SECRET}"
      CALENDSO_ENCRYPTION_KEY: "${CALENDSO_ENCRYPTION_KEY}"
      CALCOM_TELEMETRY_DISABLED: "1"
      EMAIL_FROM: "[email protected]"
      EMAIL_FROM_NAME: "Lab Cal"
      EMAIL_SERVER_HOST: "mailpit"
      EMAIL_SERVER_PORT: "1025"
  mailpit:
    image: axllent/mailpit:v1.31.5
    container_name: lab-book-cal-mailpit
    ports:
      - "127.0.0.1:28441:8025"
  radicale:
    image: kozea/radicale:3.8.3
    container_name: lab-book-cal-radicale
    ports:
      - "127.0.0.1:28442:5232"
    volumes:
      - ./radicale-config:/etc/radicale/config:ro
      - ./radicale-users:/etc/radicale/users:ro
      - radicale:/var/lib/radicale
  hook:
    image: python:3.13-alpine
    container_name: lab-book-cal-hook
    command: ["python", "-u", "/hook.py"]
    volumes:
      - ./hook.py:/hook.py:ro
volumes:
  db:
  radicale:

والأسرار في ملف .env، بالطول الذي يطلبه README، فالمفتاح CALENDSO_ENCRYPTION_KEY يجب أن يكون 32 بايت لأنه مفتاح AES-256 يشفر بيانات الدخول إلى التقويمات المربوطة:

umask 077
printf "POSTGRES_PASSWORD=%s\nNEXTAUTH_SECRET=%s\nCALENDSO_ENCRYPTION_KEY=%s\n" \
  $(openssl rand -hex 16) $(openssl rand -base64 32) $(openssl rand -base64 24) > .env
docker compose up -d

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

  • إعدادات Radicale والمستخدمين كما في دليل Radicale، وملف hook.py سيرفر HTTP من عشرين سطراً يطبع ما يصله، ونعرضه في قسم ال Webhooks.
  • إذا فقدت CALENDSO_ENCRYPTION_KEY فلن يستطيع التطبيق قراءة كلمات مرور التقويمات المربوطة، لذلك يحفظ مع النسخة الاحتياطية.
  • لم نضع خدمة calcom-api، وهي ال API v2، لأنها خدمة منفصلة تحتاج Redis، ولم نحتجها في التجربة.

ربع ساعة قبل أول صفحة

ال Image مبنية بالعنوان http://localhost:3000، فإذا اختلف NEXT_PUBLIC_WEBAPP_URL عنه فإن سكربت البداية يستبدله في كل الملفات المبنية قبل التشغيل، وهذا ما رأيناه في السجل مع الوقت:

docker logs -t lab-book-cal-app 2>&1 | grep -E "Replacing|migrate deploy|migrations found|seed-app-store|Ready in"
2026-10-11T21:27:47.835486241Z Replacing all statically built instances of http://localhost:3000 with http://127.0.0.1:28440.
2026-10-11T21:39:30.345358543Z + npx prisma migrate deploy --schema /calcom/packages/prisma/schema.prisma
2026-10-11T21:40:15.331095666Z 588 migrations found in prisma/migrations
2026-10-11T21:41:56.414232378Z + npx ts-node --transpile-only /calcom/scripts/seed-app-store.ts
2026-10-11T21:43:45.076703948Z @calcom/web:start: ✓ Ready in 9.9s

أي نحو 12 دقيقة لاستبدال العنوان، ودقيقتين لتطبيق 588 Migration على قاعدة فارغة، ثم تجهيز متجر التطبيقات، فالمجموع 16 دقيقة على جهاز التجربة وقرصه مشغول بتجارب أخرى. ويتكرر الاستبدال عند كل إعادة إنشاء لل Container، فلا تفسر الصمت الطويل في السجل على أنه تعطل. وحجم ال Image على القرص 8.05 GB، وقد استهلك التطبيق 1.7 GB من الذاكرة بعد التجربة، وقاعدة البيانات 54 MB، فلا ننصح بسيرفر بذاكرة أقل من 4 GB.

الإعداد الأول واختيار الترخيص

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

خطوة اختيار الترخيص في معالج إعداد Cal.com: بطاقة AGPLv3 مجانية دون ميزات المؤسسات، وبطاقة إدخال مفتاح ترخيص يبدأ من 15 دولاراً للمستخدم شهرياً
AGPLv3 مجاناً دون ميزات المؤسسات، أو مفتاح ترخيص لكل الميزات

تركنا الاختيار على AGPLv3، ثم أكملنا خطوات التهيئة Onboarding: الاستخدام الشخصي، والاسم ورابط الصفحة، ثم ربط التقويم، وفيها هذه الخيارات:

خطوة ربط التقويم في Cal.com، وفيها Apple Calendar وCalDav بيتا وNotion Calendar وMicrosoft Exchange وICS Feed، دون Google Calendar
خيارات ربط التقويم دون إعداد إضافي، وGoogle غير موجود هنا

ولاحظ أن Google Calendar غير موجود في القائمة، والسبب أنه يحتاج عميل OAuth من Google Cloud تضعه في المتغير GOOGLE_API_CREDENTIALS بحسب قسم Obtaining the Google API Credentials، ومعه عنوان رجوع على نطاقك العام، وكذلك Microsoft 365. وبعد الدخول تظهر في القائمة الجانبية روابط Teams وRouting وWorkflows وInsights، وهي ميزات تحتاج مفتاح الترخيص في v6.2.0، وغير موجودة في Cal.diy أصلاً. ويظهر شريط برتقالي في أعلى كل صفحة يقول إن صلاحيات المدير لا تعمل حتى تكون كلمة المرور 15 حرفاً أو يفعل التحقق بخطوتين 2FA، وقد ظهر عندنا مع أن كلمة المرور 16 حرفاً، فاعتبره تذكيراً بتفعيل 2FA لحساب المدير.

نوع الاجتماع وجدول التوفر

جدول التوفر Availability هو ساعات العمل التي يقدم منها Cal.com الأوقات، وهو منفصل عن نوع الاجتماع، فيمكن أن يستخدم عدة أنواع الجدول نفسه. أنشأنا جدولاً اسمه Clinic hours من الأحد إلى الخميس من 9:00 إلى 17:00 بالمنطقة Asia/Riyadh، ولاحظ أن الجدول الافتراضي عند الإنشاء من الإثنين إلى الجمعة، فغيرنا الأحد والجمعة:

صفحة جدول التوفر Clinic hours في Cal.com، فيها الأحد إلى الخميس من 9:00 صباحاً إلى 5:00 مساء، والجمعة والسبت مغلقة، والمنطقة الزمنية Asia/Riyadh، وقسم استثناءات التواريخ
جدول التوفر: الأيام والساعات والمنطقة الزمنية، وتحتها استثناءات التواريخ Date Overrides للإجازات

وتحت الأيام قسم Date overrides، وهو المكان الذي تضع فيه إجازة أو يوماً بساعات مختلفة. وقد ظهر لنا عند أول دخول تنبيه يقول إن منطقة الحساب تختلف عن منطقة المتصفح Asia/Riyadh ويسأل إن كنا نريد تحديثها، وهذا تنبيه مفيد، والسبب أن الحساب يبدأ بمنطقة السيرفر، وقد رأينا في دليل Easy!Appointments ماذا يحدث عندما تختلف المنطقة دون أن ينبهك أحد.

ثم أنشأنا نوع اجتماع باسم «مكالمة تعارف» مدته 30 دقيقة، ورابطه /owner/intro-30، والصورة التالية تبين صفحته، ولاحظ في القائمة اليسرى الإعدادات الأخرى: الحدود Limits مثل أقل مهلة قبل الحجز Minimum notice، والمتقدم Advanced مثل طلب التأكيد قبل قبول الحجز Requires confirmation، والتكرار، وال Webhooks الخاصة بهذا النوع:

صفحة نوع الاجتماع مكالمة تعارف في Cal.com، فيها العنوان والوصف والرابط owner/intro-30 والمدة 30 دقيقة والمكان، وقائمة جانبية بالتوفر والحدود والإعدادات المتقدمة والتكرار والتطبيقات وسير العمل وال Webhooks
نوع الاجتماع: العنوان والرابط والمدة والمكان، والإعدادات الأخرى في القائمة الجانبية

والمكان الافتراضي للاجتماع Cal Video، وهو يعتمد على خدمة Daily.co ويحتاج مفتاحها في المتغير DAILY_API_KEY، فدونه يصل في البريد أن المكان Cal Video ولا يعمل الرابط، لذلك اختر مكاناً حضورياً أو تطبيق Jitsi من متجر التطبيقات، أو اربط سيرفر الاجتماعات الخاص بك.

ربط تقويم CalDAV

أنشأنا تقويماً في Radicale بالطلب MKCALENDAR، ثم من Apps اخترنا CalDav (Beta) وأدخلنا العنوان http://radicale:5232/sara/meetings/ واسم المستخدم وكلمة المرور، فاتصل مباشرة، وفي سجل Radicale طلبات PROPFIND ناجحة من التطبيق، بخلاف Easy!Appointments الذي يرفض سيرفر CalDAV على عنوان خاص. وبعد الربط تظهر صفحة التقويمات كما يلي:

صفحة التقويمات المثبتة في Cal.com: قسم إضافة الأحداث إلى تقويم sara/meetings، وقسم فحص التعارض فيه تقويم CalDav والمفتاح الخاص به مغلق
قسمان منفصلان: أين يضاف الموعد، وأي التقويمات تفحص للتعارض، والمفتاح الثاني مغلق افتراضياً

ولاحظ في الصورة أن الصفحة فيها قسمان: «Add to calendar» وهو التقويم الذي تكتب فيه المواعيد الجديدة، و«Check for conflicts» وهي التقويمات التي يقرأ منها الأوقات المشغولة، ومفتاح التقويم في القسم الثاني مغلق بعد الربط. ولنرى أثر ذلك أضفنا في Radicale موعداً خاصاً يوم الثلاثاء 13 أكتوبر من 10:00 إلى 11:00 بتوقيت الرياض:

BEGIN:VEVENT
UID:lab-busy-1
DTSTART:20261013T070000Z
DTEND:20261013T080000Z
SUMMARY:Dentist (private)
END:VEVENT
curl -s -u 'sara:********' -X PUT -H 'Content-Type: text/calendar' --data-binary @busy.ics \
  http://127.0.0.1:28442/sara/meetings/lab-busy-1.ics -o /dev/null -w "PUT %{http_code}\n"
PUT 201

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

before: 9:00am 9:30am 10:00am 10:30am 11:00am 11:30am 12:00pm 12:30pm 1:00pm 1:30pm 2:00pm 2:30pm 3:00pm 3:30pm 4:00pm 4:30pm
after:  9:00am 9:30am 11:00am 11:30am 12:00pm 12:30pm 1:00pm 1:30pm 2:00pm 2:30pm 3:00pm 3:30pm 4:00pm 4:30pm

أي أن الربط وحده يكتب المواعيد في تقويمك ولا يمنع التعارض، فقبل تفعيل المفتاح عرض Cal.com الوقتين 10:00 و10:30 مع أن التقويم مشغول، وبعد تفعيله اختفيا. لذلك بعد ربط أي تقويم افتح هذه الصفحة وفعل فحص التعارض لكل تقويم فيه مواعيدك، وليس فقط التقويم الذي يكتب فيه Cal.com.

مسار الحجز كاملاً

الصورة التالية تبين ما يحدث من فتح الرابط إلى وصول ال Webhook، والخطوتان 2 و6 لا تحدثان إلا مع تقويم مربوط:

مخطط مرقم لحجز في Cal.com: المدعو يفتح الرابط، ثم يقرأ التطبيق الأوقات المشغولة من تقويم CalDAV، ثم يعرض الأوقات الفارغة، ثم يختار المدعو ويؤكد، ثم يحفظ الحجز في PostgreSQL، ويكتب الموعد في التقويم، ويرسل البريد، ويرسل Webhook باسم BOOKING_CREATED إلى نظامك
ثماني خطوات لحجز واحد، أربع منها اتصالات تخرج من Cal.com

فتحنا صفحة الحجز كمدعو من متصفح منطقته Asia/Riyadh، واخترنا يوم 13 أكتوبر الساعة 11:00، وكتبنا الاسم والبريد، فظهرت صفحة التأكيد:

صفحة تأكيد الحجز في Cal.com: This meeting is scheduled، مع العنوان مكالمة تعارف بين Clinic Owner وFahad Alsalem، والوقت الثلاثاء 13 أكتوبر 11:00 بتوقيت Arabian Standard Time، ومكان الاجتماع Cal Video
صفحة التأكيد بعد الحجز، والوقت بمنطقة المدعو

وبعد ثوان وجدنا النتائج الثلاث، أولها البريد في Mailpit:

[email protected] | مكالمة تعارف between Clinic Owner and Fahad Alsalem
[email protected] | مكالمة تعارف between Clinic Owner and Fahad Alsalem
[email protected] | [Action Required] Confirmed: intro-30 with Fahad Alsalem at 11:00am - 11:30am, Tuesday, October 13, 2026

وثانيها الموعد في تقويم Radicale، بجوار الموعد الخاص الذي أضفناه:

<href>/sara/meetings/tmJ6ReYK982ZYfkWhVgeP5.ics</href>
<href>/sara/meetings/lab-busy-1.ics</href>

وثالثها ال Webhook كما في القسم التالي. ولاحظ في عنوان الرسالة أن اسم الموعد بالعربية والباقي بالإنجليزية، لأن قالب اسم الحدث الافتراضي في ملف الترجمة الإنجليزية هو «{{eventName}} between {{host}} and {{attendeeName}}»، ويحفظ العنوان مع الحجز عند إنشائه، وقد كانت لغة الحساب الإنجليزية عند حجوزاتنا فبقيت العناوين كذلك بعد التحويل إلى العربية، فإذا أردته عربياً كاملاً فاكتب الاسم بنفسك في خيار Calendar event name في الإعدادات المتقدمة لنوع الاجتماع.

ال Webhooks وتوقيعها

من Settings ثم Developer ثم Webhooks أضفنا Webhook إلى http://hook:8080/cal مع سر Secret هو lab-cal-secret، ويمكن أن تختار الأحداث التي تطلقه، مثل إنشاء الحجز وإلغائه وتغيير موعده. وهذا ملف hook.py الذي يستقبله ويتحقق من التوقيع:

# Prints every webhook it receives and checks the Cal.com HMAC signature.
import http.server, json, sys, hmac, hashlib
class H(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get('Content-Length', 0)))
        print('POST', self.path, flush=True)
        for k, v in self.headers.items():
            if k.lower().startswith('x-') or k.lower() in ('content-type', 'user-agent'): print(f'{k}: {v}', flush=True)
        sig = self.headers.get('X-Cal-Signature-256')
        if sig: print('hmac ok:', hmac.compare_digest(sig, hmac.new(b'lab-cal-secret', body, hashlib.sha256).hexdigest()), flush=True)
        try: print(json.dumps(json.loads(body), ensure_ascii=False, indent=2)[:3000], flush=True)
        except Exception: print(body[:2000], flush=True)
        self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
    def log_message(self, *a): pass
http.server.HTTPServer(('0.0.0.0', 8080), H).serve_forever()

وهذا جزء مما وصل عند حجز:

POST /cal
Content-Type: application/json
X-Cal-Signature-256: 1b93e4ca3222bdb81c3ab9b06909a7f990994b6fb8fae293086530712c41b451
X-Cal-Webhook-Version: 2021-10-20
user-agent: node
{
  "triggerEvent": "BOOKING_CREATED",
  "createdAt": "2026-10-11T22:16:40.562Z",
  "payload": {
    "title": "مكالمة تعارف between Clinic Owner and Fahad Alsalem",
    "startTime": "2026-10-13T08:00:00+00:00",
    "endTime": "2026-10-13T08:30:00+00:00",
    "type": "intro-30",
    "organizer": {
      "email": "[email protected]",
      "timeZone": "Asia/Riyadh",
      ...
    },
    "attendees": [
      {
        "email": "[email protected]",
        "name": "Fahad Alsalem",
        "timeZone": "Asia/Riyadh",
        ...

وبعد أن أضفنا التحقق إلى السكربت وحجزنا موعداً آخر طبع السطر التالي:

hmac ok: True

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

  • الترويسة X-Cal-Signature-256 هي HMAC-SHA256 لمحتوى الطلب كما وصل، بالسر الذي أدخلته، فاحسبها من البايتات الخام قبل تحويلها إلى JSON، وارفض الطلب إذا لم تتطابق، والسبب أن عنوان ال Webhook قد يعرفه غيرك.
  • الوقت بتوقيت UTC ومعه منطقة كل طرف، فيستطيع نظامك أن يعرض الوقت لكل شخص بمنطقته دون تخمين.
  • ال Webhook يمكن أن يكون عاماً لكل أنواع الاجتماعات من الإعدادات، أو خاصاً بنوع واحد من صفحة النوع نفسه.

الواجهة العربية

ملف الترجمة العربية في الإصدار v6.2.0 فيه 4630 نصاً من أصل 4647 في الملف الإنجليزي، أي ينقصه 18 نصاً، وهو من 44 لغة في المجلد packages/i18n/locales. وصفحة الحجز تأخذ لغة المتصفح، فإذا كانت العربية صارت الصفحة كلها من اليمين إلى اليسار، كما في الصورة التالية:

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

وكذلك لوحة الإدارة بعد أن تختار العربية لغة لحسابك من الإعدادات، والصورة التالية تبين قائمة الحجوزات، ولاحظ أن عناوين المواعيد نصف إنجليزية بسبب القالب الذي ذكرناه، وأن صيغة الوقت am وpm في هذه الصفحة:

قائمة الحجوزات القادمة في Cal.com بالعربية من اليمين إلى اليسار، فيها عناوين مثل مكالمة تعارف between Clinic Owner and Fahad Alsalem، وحجزان متداخلان يوم الأحد 18 أكتوبر من 10:00 ومن 10:15
لوحة الإدارة بالعربية، ويومها الأحد 18 أكتوبر فيه حجزان متداخلان من تجربة القسم التالي

وهذا أفضل دعم للعربية بين أدوات الحجز الأربع التي شغلناها، فهو الوحيد الذي يقلب الاتجاه فعلاً.

اكسرها: عشرة أشخاص على الوقت نفسه

كررنا هنا تجربة الحجز المتزامن التي قبل فيها Easy!Appointments سبعة من عشرة، فأرسلنا عشرة طلبات في اللحظة نفسها إلى نقطة الحجز العامة /api/book/event التي تستخدمها صفحة الحجز، لموعد الساعة 10:00 يوم 15 أكتوبر:

# race.py: N invitees try to book the same Cal.com slot at the same moment
import sys, json, threading, urllib.request
BASE = 'http://127.0.0.1:28440'
N = int(sys.argv[1]); START, END = sys.argv[2], sys.argv[3]

def worker(i, barrier, results):
    body = {'responses': {'name': f'Guest {i}', 'email': f'guest{i}@example.com',
                          'location': {'value': 'integrations:daily', 'optionValue': ''}},
            'start': START, 'end': END, 'eventTypeId': 4, 'eventTypeSlug': 'intro-30', 'user': 'owner',
            'timeZone': 'Asia/Riyadh', 'language': 'en', 'metadata': {}}
    req = urllib.request.Request(BASE + '/api/book/event', data=json.dumps(body).encode(),
                                 headers={'Content-Type': 'application/json'})
    barrier.wait()
    try:
        with urllib.request.urlopen(req, timeout=120) as r: results[i] = (r.status, json.loads(r.read()).get('uid'))
    except urllib.error.HTTPError as e:
        results[i] = (e.code, e.read().decode()[:160])

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])
print('accepted:', sum(1 for s, _ in results.values() if s == 200))
python3 race.py 10 2026-10-15T07:00:00.000Z 2026-10-15T07:30:00.000Z
0 400 {"message":"An error occurred while querying the database.","data":{"traceId":"trace_8u8bb_3b0BWX70ruMApis"}}
1 400 {"message":"An error occurred while querying the database.","data":{"traceId":"trace_xSealLZs3BBOePEySOWUU"}}
2 400 {"message":"An error occurred while querying the database.","data":{"traceId":"trace_zxDu0W0RT7YiO0VLxm-_k"}}
3 400 {"message":"An error occurred while querying the database.","data":{"traceId":"trace_ma07YHqDERkh1CYb4DSsn"}}
4 200 jJzE3JgwPVUyZXwZHUTVcN
5 400 {"message":"An error occurred while querying the database.","data":{"traceId":"trace__scF1oZO5NtL1lbwwRDej"}}
6 400 {"message":"An error occurred while querying the database.","data":{"traceId":"trace_CtyvpPQ8oivk4qNXNB2Jq"}}
7 400 {"message":"An error occurred while querying the database.","data":{"traceId":"trace_TAgO2rQUnZfgqjdRccGUv"}}
8 400 {"message":"An error occurred while querying the database.","data":{"traceId":"trace_eZ1z2v5ZJgp7sprhr6qEm"}}
9 400 {"message":"An error occurred while querying the database.","data":{"traceId":"trace_t7oudpYFrrNvBv8i6iRhN"}}
accepted: 1

حجز واحد فقط، والتسعة الباقية رفضتها قاعدة البيانات، وفي سجل التطبيق الخطأ P2002 من Prisma على القيد Booking_idempotencyKey_key. وعند البحث في الكود وجدنا أن Cal.com يضيف إلى كل حجز مقبول مفتاحاً اسمه idempotencyKey، يحسبه من وقت البداية ووقت النهاية ورقم المستخدم، وعليه قيد فريد في الجدول:

docker exec lab-book-cal-db psql -U calcom -d calcom -c '\d "Booking"' | grep -i unique
    "Booking_idempotencyKey_key" UNIQUE, btree ("idempotencyKey")
    "Booking_oneTimePassword_key" UNIQUE, btree ("oneTimePassword")
    "Booking_uid_key" UNIQUE, btree (uid)

وهذا هو خط الدفاع الأخير الذي شرحناه في مقال نظام حجز التذاكر. ولكن لاحظ أن المفتاح يحسب من البداية والنهاية بالضبط، فهو يمنع الحجز المكرر للوقت نفسه، ولا يمنع حجزين متداخلين بأوقات مختلفة. فأرسلنا طلبين في اللحظة نفسها: «مكالمة تعارف» 30 دقيقة من 10:00، ونوع آخر مدته 15 دقيقة من 10:15، مع المستخدم نفسه، وكررنا ذلك ثلاث مرات:

== 7
intro-30 2026-10-18T07:00:00.000Z 200
15min 2026-10-18T07:15:00.000Z 200
== 8
intro-30 2026-10-18T08:00:00.000Z 200
15min 2026-10-18T08:15:00.000Z 200
== 10
intro-30 2026-10-18T10:00:00.000Z 200
15min 2026-10-18T10:15:00.000Z 200

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

409 {"message":"no_available_users_found_error","data":{"traceId":"trace_sbaUMcXM97aH16yONkSt1"}}

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

قبل الإنتاج

  1. قرر أي كود تشغل: v6.2.0 الجاهزة دون تحديثات، أو Cal.diy تبنيه بنفسك، واكتب من المسؤول عن متابعة الثغرات، وإذا لم يوجد فانظر إلى Easy!Appointments أو إلى صفحات الحجز في تقويمك الحالي.
  2. لا تنشر Prisma Studio ولا Redis: ملف Compose الرسمي ينشر المنفذين 5555 و6379، فاحذفهما أو اربطهما ب 127.0.0.1.
  3. HTTPS: اجعل NEXT_PUBLIC_WEBAPP_URL وNEXTAUTH_URL عنوان HTTPS العام، وضع التطبيق خلف Caddy أو غيره، وتذكر أن تغيير العنوان يعيد الاستبدال الطويل عند التشغيل.
  4. حساب المدير: فعل 2FA، وأغلق التسجيل العام بالمتغير NEXT_PUBLIC_DISABLE_SIGNUP إذا كان السيرفر لفريقك فقط.
  5. التقويمات: فعل فحص التعارض لكل تقويم، وغير مكان الاجتماع الافتراضي إذا لم يكن لديك مفتاح Daily.
  6. البريد: اضبط SMTP حقيقياً كما في مقال Transactional Email.
  7. النسخ الاحتياطي: قاعدة PostgreSQL مع ملف .env، وخاصة CALENDSO_ENCRYPTION_KEY، واختبر الاستعادة كما في قاعدة 3-2-1.

الخلاصة

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

  • منذ 15 أبريل 2026 أصبح Cal.com مغلق المصدر، والمستودع العام Cal.diy بترخيص MIT دون الفرق وسير العمل وSSO والإحصاءات، ويقول عن نفسه إنه ليس للإنتاج.
  • لا يوجد Docker Image لـ Cal.diy، وآخر Image رسمية هي calcom/cal.com:v6.2.0 من مارس 2026، وحجمها 8 GB على القرص، وأول تشغيل بعنوان مختلف استغرق 16 دقيقة عندنا.
  • ربط CalDAV يعمل مع سيرفر داخلي، ولكن فحص التعارض مغلق افتراضياً، فدونه عرض Cal.com أوقاتاً مشغولة في تقويمنا.
  • ال Webhooks موقعة بال HMAC-SHA256، والأوقات فيها بتوقيت UTC مع منطقة كل طرف.
  • العربية مكتملة تقريباً وباتجاه RTL صحيح، وقالب اسم الموعد يبقى بالإنجليزية حتى تغيره.
  • قيد فريد يمنع حجز الوقت نفسه مرتين، ولكنه لا يمنع حجزين متداخلين بمدد مختلفة في اللحظة نفسها.
دليلأي أداة حجز مواعيد تناسب عيادتك أو فريقك؟أربع حالات حجز مختلفة، من العيادة إلى قاعة الاجتماعات، والأداة مفتوحة المصدر التي تناسب كل حالة منها.دليلEasy!Appointments: حجز مواعيد العيادات ومراكز الخدمةكيف تشغل Easy!Appointments على سيرفرك لحجز الخدمات مع مقدميها، وما الذي يحتاج انتباهك في العربية والمنطقة الزمنية.دليلالتقويم وجهات الاتصال على سيرفرك مع CalDAV وCardDAVكيف تبقى مواعيدك وجهات اتصالك متطابقة بين الهاتف والحاسوب على سيرفرك، وما الذي يعمل مع Outlook وما الذي لا يعمل.دليلRadicale: سيرفر تقويم وجهات اتصال خفيف لفريقككيف تشغل Radicale خلف Reverse Proxy بكلمات مرور bcrypt وتقويم مشترك للفريق، وتحفظ كل تغيير في git لتسترجعه عند الخطأ.

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

  • أكتوبر 2026: كتابة المقال، والتجربة العملية على calcom/cal.com:v6.2.0 مع PostgreSQL 17.6 وRadicale 3.8.3 وMailpit v1.31.5 على Docker Engine 29.8.0، وقراءة ملفات الترخيص في Cal.diy عند الوسم v6.2.0 والفرع الرئيسي.
نشرة عرب رووت | ArabRoot

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

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

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

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