Files
hamedandClaude Opus 5 4049daf071 feat: close the last four domain events, and the panel paths they describe
Every one of the fourteen named events now has an emit point. The four that
were missing all sat on paths owned by earlier tasks:

- AppointmentCompleted fires from both status-change routes, after the row is
  saved. A rejected transition or a version conflict leaves no event; otherwise
  the completed count runs ahead of the appointments themselves.
- AppointmentRescheduled is a third event, not a replacement. A rebook is a
  confirm plus a cancel, and a consumer that only hears the cancel messages a
  patient who still has an appointment.
- ResourceBlocked / ResourceReleased are a pair. Capacity coming back has to be
  as audible as capacity going away, or the resource reads as permanently taken.

Publishing is now on the scheduler rather than an unregistered command: the
logic moved out of PublishDomainEventsCommand into OutboxPublisher so the
recurring message and the manual command share it, and the existing
worker-scheduler container consumes it. The scheduler message carries no data
on purpose — what to publish is read from the table, so an event recorded
between two ticks is not skipped. DomainEventMessage routes to async, since a
slow consumer was otherwise slowing the drain itself and its failure marked a
row failed that had in fact been delivered.

Panel work that these paths made reachable:

- Cancelling from the appointment page now goes through the policy-aware
  endpoint and shows the penalty preview before the confirm, so the operator
  does not discover the patient's penalty after the fact. The cancellation
  service writes the timeline entry itself and accepts a reason, which that
  path previously dropped on the floor.
- Rescheduling reuses the booking page under ?rebook=<uuid> — the search and
  hold steps are identical and only the final step differs. The doctor picker
  is hidden there: a reschedule is not an invitation to change doctors.
- A new GET /appointment/{uuid}/segments exposes the recorded plan. An empty
  list is not an error, it means the appointment is slot-based, and that is
  exactly what gates the resource-mode reschedule button.

AppointmentInvoiceCard no longer crashes the whole detail page when an older
invoice has no discount breakdown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:27:55 +03:30

10 KiB
Raw Permalink Blame History

دیپلوی ClinicPro روی Coolify (Docker Compose)

این راهنما نحوهٔ دیپلوی بک‌اند ClinicPro (Symfony 7.4 + پنل React) را روی Coolify با Build Pack از نوع Docker Compose توضیح می‌دهد.

همهٔ فایل‌های دیپلوی در ریشهٔ ریپو هستند: Dockerfile، docker-compose.yml، پوشهٔ docker/ و .env.coolify.example. فایل‌های compose.yaml، compose.override.yaml و .ddev/ مخصوص محیط لوکال (ddev) هستند و در دیپلوی نقشی ندارند.


معماری دیپلوی

docker-compose.yml پنج سرویس بالا می‌آورد:

سرویس نقش نکته
app وب (PHP-FPM + Nginx) دامنه به این سرویس اختصاص می‌یابد (پورت ۸۰). RUN_INIT=1 → migration و تولید کلید JWT
worker-async مصرف صف async (ارسال SMS، مصرف‌کنندهٔ رویدادهای دامنه) RUN_INIT=0
worker-scheduler مصرف scheduler_default (انقضای نوبت‌ها، انتشار صندوق خروجی رویدادها، هر دقیقه) RUN_INIT=0
mariadb دیتابیس MariaDB 11.8 healthcheck دارد؛ سرویس‌های اپ منتظر سالم‌شدن آن می‌مانند
redis Messenger transport + کش/OTP با appendonly persist می‌شود

هر سه سرویس اپ از یک image یکسان (همان Dockerfile) ساخته می‌شوند و فقط command/RUN_INIT آن‌ها متفاوت است.


مراحل دیپلوی

۱. ساخت Resource در Coolify

  1. New Resource → Public/Private Repository و ریپوی clinicpro را انتخاب کن.
  2. Build Pack را روی Docker Compose بگذار (نه Nixpacks).
  3. در تنظیمات:
    • Branch: main
    • Base Directory: / (ریشهٔ ریپو)
    • Docker Compose File: docker-compose.yml

۲. اختصاص دامنه

  • در سرویس app، همهٔ دامنه‌هایی که باید سرویس بگیرند را وارد کن — هم دامنهٔ API بک‌اند و هم همهٔ دامنه‌های فرانت‌اند (nobat724.com و همهٔ *-nobat.ir). Coolify لیست دامنهٔ کامادار را روی یک سرویس می‌پذیرد.
  • چون کانتینر روی پورت 80 گوش می‌دهد، نیازی به افزودن پورت به دامنه نیست.
  • Coolify به‌صورت خودکار برای هر دامنه TLS را از طریق Traefik (Let's Encrypt) صادر می‌کند.

دو مفهوم را اشتباه نگیر:

  • اختصاص دامنه در UI = Traefik برای آن دامنه روت و گواهی TLS می‌سازد.
  • CORS_ALLOW_ORIGIN / ALLOWED_FRONTEND_HOSTS = سیمفونی به آن origin اجازهٔ مرورگری/پرداخت می‌دهد.

یک دامنهٔ جدید معمولاً به هر دو نیاز دارد: هم در UI کولیفای اضافه شود، هم در docker/frontend-domains.json (و سپس بازتولید env). به بخش «چند دامنه فرانت‌اند» پایین مراجعه کن.

۳. متغیرهای محیطی

محتوای .env.coolify.example را در تب Environment Variables وارد کن. الزامی‌ها پیش از اولین دیپلوی:

متغیر توضیح
APP_SECRET php -r "echo bin2hex(random_bytes(32));"
JWT_PASSPHRASE openssl rand -hex 32باید قبل از اولین استارت موجود باشد (کلید JWT با آن ساخته می‌شود)
DB_PASSWORD پسورد یوزر دیتابیس
DB_ROOT_PASSWORD پسورد root مریادی‌بی
APP_BASE_URL دامنهٔ خودِ بک‌اند (مثلاً https://api.nobat724.com) — برای callback پرداخت و URL مطلق
ALLOWED_FRONTEND_HOSTS / CORS_ALLOW_ORIGIN دامنه‌های فرانت‌اند (چندتایی) — به بخش «چند دامنه» پایین مراجعه کن
TRUSTED_PROXIES پیش‌فرض رنج شبکهٔ داخلی داکر (در فایل نمونه هست)
API_IR_TOKEN توکن استعلام هویت (خالی = استعلام رد می‌شود)

DATABASE_URL، MESSENGER_TRANSPORT_DSN و REDIS_URL در خودِ compose از نام سرویس‌ها (mariadb/redis) ساخته می‌شوند؛ در UI تعریف نکن. کلیدهای SMS و درگاه پرداخت از DB («تنظیمات سایت») خوانده می‌شوند، نه از env. می‌توانی به‌جای hardcode از magic variableهای Coolify استفاده کنی، مثلاً DB_PASSWORD=${SERVICE_PASSWORD_DB}.

چند دامنه فرانت‌اند (مهم)

این بک‌اند به ده‌ها دامنهٔ شهری سرویس می‌دهد (nobat724.com و *-nobat.ir). دو متغیر باید همهٔ این دامنه‌ها را پوشش دهند:

  • ALLOWED_FRONTEND_HOSTS — لیست host با کاما؛ در validate کردن host بازگشتِ پرداخت استفاده می‌شود (تطبیق دقیق در PaymentController::isAllowedFrontend).
  • CORS_ALLOW_ORIGIN — یک regex واحد (nelmio با origin_regex: true) که فقط https و دقیقاً همان host‌ها را می‌پذیرد.

هر دو مقدار به‌صورت خودکار از فایل docker/frontend-domains.json تولید می‌شوند. برای افزودن یا حذف یک دامنه:

# ۱) یک رکورد به آرایهٔ "domains" در docker/frontend-domains.json اضافه/حذف کن، مثلاً:
#    { "domain": "newcity-nobat.ir", "label": "شهر جدید" }
# ۲) مقادیر جدید را تولید کن:
ddev exec php docker/gen-cors-env.php       # لوکال
#   یا روی سرور داخل کانتینر app:
php docker/gen-cors-env.php
# ۳) خروجی (CORS_ALLOW_ORIGIN و ALLOWED_FRONTEND_HOSTS) را در Coolify جایگزین کن و دوباره deploy کن

فیلد label فقط برای خوانایی است و در تولید env استفاده نمی‌شود؛ فقط domain مهم است.

payment_allowed_frontend_hosts در «تنظیمات سایت» (DB) بر مقدار env اولویت دارد؛ اگر آن را در DB ست کرده‌ای، آن مرجع است.

۴. Persistent Storage (حیاتی)

این volumeها در compose تعریف شده‌اند و Coolify آن‌ها را persist می‌کند:

Volume مسیر چرا مهم است
jwt_keys /app/config/jwt مهم‌ترین. کلید JWT بین دیپلوی‌ها باید ثابت بماند؛ در غیر این صورت هر دیپلوی همهٔ توکن‌ها را باطل و همهٔ کاربران را logout می‌کند
uploads_public /app/public/uploads فایل‌های آپلودی عمومی
uploads_var /app/var/uploads فایل‌های آپلودی خصوصی
mariadb_data /var/lib/mysql دادهٔ دیتابیس
redis_data /data پایداری Redis

مطمئن شو در Coolify این volumeها به‌صورت named volume (نه ephemeral) باقی می‌مانند.

۵. اولین دیپلوی و ساخت ادمین

  1. Deploy را بزن. سرویس app هنگام استارت به‌صورت خودکار:
    • کلید JWT می‌سازد (اگر روی volume نباشد)،
    • کش prod را warm می‌کند،
    • migrationها را با --all-or-nothing اجرا می‌کند.
  2. بعد از سالم‌شدن سرویس‌ها، از Terminal سرویس app در Coolify، ادمین اولیه را بساز:
    php bin/console app:create-admin
    

نکات عملیاتی

  • Worker‌ها: اگر worker-scheduler بالا نباشد، نوبت‌های رزرو ولی پرداخت‌نشده آزاد نمی‌شوند. اگر worker-async بالا نباشد، SMS ارسال نمی‌شود. هر دو در همین compose مدیریت می‌شوند و با restart: unless-stopped خودکار بازمی‌گردند.
  • Migration در دیپلوی‌های بعدی: فقط سرویس app (با RUN_INIT=1) migration اجرا می‌کند تا بین سرویس‌ها race رخ ندهد. هر دیپلوی، migrationهای جدید را اعمال می‌کند.
  • Health check: سرویس app با یک fsockopen روی پورت 80 سالم‌بودن خود را گزارش می‌دهد.
  • Trusted Proxies: مقدار TRUSTED_PROXIES به Symfony می‌گوید به هدرهای X-Forwarded-* از Traefik اعتماد کند تا https و IP واقعی کلاینت درست تشخیص داده شوند. در محیط لوکال (ddev) این متغیر تنظیم نمی‌شود و مقدار پیش‌فرض خالی است.
  • بدون شبکهٔ سفارشی: طبق توصیهٔ Coolify، در compose هیچ networks: سفارشی تعریف نشده تا روتینگ Traefik پایدار بماند.

رفع اشکال

نشانه علت محتمل راه‌حل
همهٔ کاربران بعد از دیپلوی logout می‌شوند volume jwt_keys persist نشده بررسی named volume بودن آن
خطای اتصال به دیتابیس هنگام استارت app قبل از سالم‌شدن mariadb بالا آمده depends_on: condition: service_healthy این را پوشش می‌دهد؛ صبر کن یا لاگ mariadb را ببین
پنل admin سفید/بدون استایل دارایی‌های public/build ساخته نشده بررسی موفقیت stage assets در لاگ build (yarn build)
تولید کلید JWT شکست می‌خورد JWT_PASSPHRASE تنظیم نشده متغیر را در Coolify ست کن و دوباره deploy کن
تصاویر/فایل‌های آپلودی بعد از ری‌دیپلوی ناپدید می‌شوند volumeهای uploads persist نشده بررسی uploads_public / uploads_var