ترسل لعميل أو مرشح لوظيفة رسالة تقول «متى يناسبك؟»، فيرد بوقتين لا يناسبانك، فترد بثلاثة أوقات أخرى، وبعد يومين من الرسائل تتفقان على موعد، وهذه هي المشكلة التي تحلها خدمة 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.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 حرفاً على الأقل، فيها حروف كبيرة وصغيرة ورقم، والخطوة الثانية تبين التقسيم الذي قرأناه في ملف الترخيص:

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

ولاحظ أن 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، ولاحظ أن الجدول الافتراضي عند الإنشاء من الإثنين إلى الجمعة، فغيرنا الأحد والجمعة:

وتحت الأيام قسم Date overrides، وهو المكان الذي تضع فيه إجازة أو يوماً بساعات مختلفة. وقد ظهر لنا عند أول دخول تنبيه يقول إن منطقة الحساب تختلف عن منطقة المتصفح Asia/Riyadh ويسأل إن كنا نريد تحديثها، وهذا تنبيه مفيد، والسبب أن الحساب يبدأ بمنطقة السيرفر، وقد رأينا في دليل Easy!Appointments ماذا يحدث عندما تختلف المنطقة دون أن ينبهك أحد.
ثم أنشأنا نوع اجتماع باسم «مكالمة تعارف» مدته 30 دقيقة، ورابطه /owner/intro-30، والصورة التالية تبين صفحته، ولاحظ في القائمة اليسرى الإعدادات الأخرى: الحدود Limits مثل أقل مهلة قبل الحجز Minimum notice، والمتقدم Advanced مثل طلب التأكيد قبل قبول الحجز Requires confirmation، والتكرار، وال 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 على عنوان خاص. وبعد الربط تظهر صفحة التقويمات كما يلي:

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

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

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

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

وهذا أفضل دعم للعربية بين أدوات الحجز الأربع التي شغلناها، فهو الوحيد الذي يقلب الاتجاه فعلاً.
اكسرها: عشرة أشخاص على الوقت نفسه
كررنا هنا تجربة الحجز المتزامن التي قبل فيها 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.000Z0 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 يحمي الحالة الأكثر شيوعاً، أي شخصين على الزر نفسه، بقيد في قاعدة البيانات، وأن التداخل بين أنواع اجتماع بمدد مختلفة يبقى ممكناً في لحظة التزامن، فإذا كان لديك أكثر من نوع اجتماع بمدد مختلفة على التقويم نفسه، فراجع الحجوزات المتداخلة، أو اجعل الأنواع بفترات تبدأ على حدود واحدة.
قبل الإنتاج
- قرر أي كود تشغل: v6.2.0 الجاهزة دون تحديثات، أو Cal.diy تبنيه بنفسك، واكتب من المسؤول عن متابعة الثغرات، وإذا لم يوجد فانظر إلى Easy!Appointments أو إلى صفحات الحجز في تقويمك الحالي.
- لا تنشر Prisma Studio ولا Redis: ملف Compose الرسمي ينشر المنفذين 5555 و6379، فاحذفهما أو اربطهما ب 127.0.0.1.
- HTTPS: اجعل
NEXT_PUBLIC_WEBAPP_URLوNEXTAUTH_URLعنوان HTTPS العام، وضع التطبيق خلف Caddy أو غيره، وتذكر أن تغيير العنوان يعيد الاستبدال الطويل عند التشغيل. - حساب المدير: فعل 2FA، وأغلق التسجيل العام بالمتغير
NEXT_PUBLIC_DISABLE_SIGNUPإذا كان السيرفر لفريقك فقط. - التقويمات: فعل فحص التعارض لكل تقويم، وغير مكان الاجتماع الافتراضي إذا لم يكن لديك مفتاح Daily.
- البريد: اضبط SMTP حقيقياً كما في مقال Transactional Email.
- النسخ الاحتياطي: قاعدة 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 والفرع الرئيسي.