Files
clinicpro/docs/tasks/task-01-project-setup/task.md
T
hamed de1a78a235 feat: Implement SMS sending functionality with KavehNegar and Rangineh providers
- Add SendSmsMessage class for encapsulating SMS message data.
- Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS.
- Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates.
- Develop SendSmsHandler for handling SMS sending messages.
- Create SmsService to manage SMS dispatching and logging.
- Add UserProfileController for managing user profiles with CRUD operations.
- Implement UserProfile entity and repository for user profile data management.
- Update symfony.lock and bootstrap.php for project dependencies and environment setup.
2026-06-09 22:00:34 +03:30

15 KiB
Raw Blame History

تسک ۰۱: راه‌اندازی پروژه و زیرساخت

توضیح

راه‌اندازی اولیه پروژه Symfony 7 با DDEV، نصب پکیج‌ها، پیکربندی JWT، Doctrine ORM، CORS، ساختار Domain-Driven و الگوهای مشترک (BaseController، DTO، Error Codes).

Endpoint ها

متد مسیر توضیح نیاز به Auth
GET /health Health Check خیر

پیش‌نیازها

ندارد — اولین تسک است.

خروجی‌های مورد انتظار

  • DDEV راه‌اندازی و در حال اجرا
  • پروژه Symfony 7 ایجاد شده
  • ساختار پوشه Domain-Driven تعریف شده
  • JWT authentication bundle پیکربندی شده
  • Doctrine ORM پیکربندی شده
  • CORS پیکربندی شده
  • BaseController با متدهای success() و error() پیاده شده
  • BaseRepository با متدهای مشترک پیاده شده
  • Error Codes و Error Response استاندارد تعریف شده
  • DTO pattern برای Input/Output تعریف شده
  • Rate Limiter پیکربندی شده
  • Symfony Messenger (Queue) راه‌اندازی شده
  • Structured Logging با Monolog پیکربندی شده
  • Swagger UI (NelmioApiDocBundle) راه‌اندازی شده — /api/doc
  • GET /health endpoint پیاده شده

زمان تخمینی

۶ تا ۸ ساعت


مراحل راه‌اندازی با DDEV

۱. ایجاد پروژه Symfony (قبل از DDEV)

mkdir clinic-pro-symfony && cd clinic-pro-symfony

# ابتدا Symfony، بعد DDEV
composer create-project symfony/skeleton . "7.*"

ddev config \
  --project-type=symfony \
  --php-version=8.3 \
  --docroot=public \
  --project-name=clinic-pro

ddev start

۲. نصب پکیج‌ها (همه در یک دستور)

ddev composer require \
  symfony/security-bundle \
  symfony/validator \
  symfony/serializer \
  symfony/property-access \
  symfony/property-info \
  symfony/uid \
  symfony/messenger \
  lexik/jwt-authentication-bundle \
  doctrine/doctrine-bundle \
  doctrine/doctrine-migrations-bundle \
  symfony/cache \
  nelmio/cors-bundle \
  symfony/rate-limiter \
  symfony/http-client \
  nelmio/api-doc-bundle \
  zircote/swagger-php \
  twig/twig \
  symfony/asset

ddev composer require --dev \
  symfony/maker-bundle \
  doctrine/data-fixtures \
  symfony/debug-bundle

۳. راه‌اندازی Redis با DDEV addon رسمی

ddev get ddev/ddev-redis
ddev restart

۴. تولید کلیدهای JWT

ddev exec php bin/console lexik:jwt:generate-keypair

۵. پیکربندی LexikJWT

فایل config/packages/lexik_jwt_authentication.yaml:

lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 3600

۶. پیکربندی Symfony Messenger (Queue)

فایل config/packages/messenger.yaml:

framework:
    messenger:
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                options:
                    auto_setup: true
        routing:
            'App\Shared\Message\SendSmsMessage': async
            'App\Shared\Message\SendNotificationMessage': async

۷. پیکربندی Swagger

فایل config/packages/nelmio_api_doc.yaml:

nelmio_api_doc:
    documentation:
        info:
            title: ClinicPro API
            description: مستندات API سیستم کلینیک‌پرو
            version: 1.0.0
        components:
            securitySchemes:
                bearerAuth:
                    type: http
                    scheme: bearer
                    bearerFormat: JWT
        security:
            - bearerAuth: []
    areas:
        path_patterns:
            - ^/api
            - ^/oauth
            - ^/health

فایل config/routes/nelmio_api_doc.yaml:

app.swagger_ui:
    path: /api/doc
    methods: GET
    defaults:
        _controller: nelmio_api_doc.controller.swagger_ui

app.swagger_json:
    path: /api/doc.json
    methods: GET
    defaults:
        _controller: nelmio_api_doc.controller.swagger

۸. پیکربندی Structured Logging

فایل config/packages/monolog.yaml (بخش prod):

monolog:
    handlers:
        main:
            type: stream
            path: '%kernel.logs_dir%/%kernel.environment%.log'
            level: info
            formatter: monolog.formatter.json
        security:
            type: stream
            path: '%kernel.logs_dir%/security.log'
            level: warning
            channels: [security]

ساختار پوشه Domain-Driven (الزامی)

src/
├── Auth/                          ← Identity Context
│   ├── Controller/
│   ├── Service/
│   ├── DTO/
│   └── Message/                  ← برای ارسال OTP async
│
├── Doctor/                        ← Clinical Context
│   ├── Controller/
│   ├── Entity/
│   │   ├── Doctor.php
│   │   └── DoctorAddress.php
│   ├── Repository/
│   ├── Service/
│   │   ├── DoctorService.php
│   │   └── ScheduleService.php   ← محاسبه free_turn و hours_of_work
│   └── DTO/
│
├── Clinic/                        ← Clinical Context
│   ├── Controller/
│   ├── Entity/
│   ├── Repository/
│   ├── Service/
│   └── DTO/
│
├── Appointment/                   ← Scheduling Context
│   ├── Controller/
│   ├── Entity/
│   ├── Repository/
│   ├── Service/
│   │   ├── AppointmentService.php
│   │   └── SlotService.php       ← محاسبه اسلات‌های خالی
│   └── DTO/
│
├── Payment/                       ← Financial Context
│   ├── Controller/
│   ├── Entity/
│   ├── Repository/
│   ├── Service/
│   └── Gateway/
│       ├── PaymentGatewayInterface.php
│       ├── MellatGateway.php
│       └── SepGateway.php
│
├── Rating/                        ← Community Context
│   ├── Controller/
│   ├── Entity/
│   ├── Repository/
│   ├── Service/
│   │   └── RatingCalculatorService.php
│   └── DTO/
│
├── Blog/
├── Category/                      ← Catalog Context
├── Representation/                ← Tenant Context
├── Secretary/
├── Insurance/
├── Settlement/
├── Sms/
│
└── Shared/                        ← کدهای مشترک
    ├── Controller/
    │   └── BaseController.php
    ├── Repository/
    │   └── BaseRepository.php
    ├── Response/
    │   ├── ApiResponse.php
    │   └── ApiError.php
    ├── DTO/
    │   └── PaginationMeta.php
    ├── Exception/
    │   └── AppException.php
    ├── Message/
    │   ├── SendSmsMessage.php
    │   └── SendNotificationMessage.php
    └── Constant/
        └── ErrorCodes.php

قانون Dependency Direction — رعایت اجباری:

Controller → Service → Repository → Entity

هیچ‌گاه:

Entity → Service   ❌
Repository → Controller  ❌
Service → Controller   ❌

Error Response استاندارد

فرمت موفق

{
  "success": true,
  "data": { ... },
  "meta": {
    "page": 1,
    "totalPages": 5,
    "totalRecords": 47
  }
}

فرمت خطا

{
  "success": false,
  "data": null,
  "errors": [
    {
      "code": "ERR_VALIDATION_001",
      "field": "mobile_number",
      "message": "فرمت شماره موبایل نادرست است"
    }
  ]
}

Error Codes — src/Shared/Constant/ErrorCodes.php

class ErrorCodes
{
    // Auth
    const ERR_AUTH_001 = 'توکن JWT منقضی شده یا نامعتبر است';
    const ERR_AUTH_002 = 'کد OTP نامعتبر است';
    const ERR_AUTH_003 = 'کد OTP منقضی شده است';
    const ERR_AUTH_004 = 'تعداد تلاش‌های OTP به حد مجاز رسیده است';

    // Validation
    const ERR_VALIDATION_001 = 'ورودی نامعتبر است';
    const ERR_VALIDATION_002 = 'فیلد الزامی وارد نشده است';

    // Not Found
    const ERR_NOT_FOUND_001 = 'منبع درخواستی یافت نشد';

    // Forbidden
    const ERR_FORBIDDEN_001 = 'دسترسی به این منبع مجاز نیست';

    // Payment
    const ERR_PAYMENT_001 = 'درگاه پرداخت در دسترس نیست';
    const ERR_PAYMENT_002 = 'مبلغ پرداخت نامعتبر است';
    const ERR_PAYMENT_003 = 'وضعیت نوبت برای پرداخت مناسب نیست';

    // Appointment
    const ERR_APPOINTMENT_001 = 'اسلات انتخاب‌شده در دسترس نیست';
    const ERR_APPOINTMENT_002 = 'نوبت قابل لغو نیست';

    // File
    const ERR_FILE_001 = 'فرمت فایل مجاز نیست';
    const ERR_FILE_002 = 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)';
}

BaseController — src/Shared/Controller/BaseController.php

abstract class BaseController extends AbstractController
{
    protected function success(mixed $data, int $status = 200, array $meta = []): JsonResponse
    {
        $response = ['success' => true, 'data' => $data];
        if (!empty($meta)) {
            $response['meta'] = $meta;
        }
        return new JsonResponse($response, $status);
    }

    protected function paginated(mixed $data, int $total, int $page, int $limit): JsonResponse
    {
        return $this->success($data, 200, [
            'totalRecords' => $total,
            'totalPages'   => (int) ceil($total / $limit),
            'currentPage'  => $page,
        ]);
    }

    protected function error(string $code, string $message, int $status = 400, ?string $field = null): JsonResponse
    {
        $err = ['code' => $code, 'message' => $message];
        if ($field) {
            $err['field'] = $field;
        }
        return new JsonResponse(['success' => false, 'data' => null, 'errors' => [$err]], $status);
    }
}

DTO Pattern

هر endpoint باید DTO جداگانه داشته باشد:

// Input DTO (Request)
class DoctorCreateRequest
{
    #[Assert\NotBlank(message: 'نام دکتر الزامی است')]
    public string $title;

    #[Assert\Choice(['man', 'woman'])]
    public string $gender;

    #[Assert\Range(min: 0, max: 100)]
    public int $experience;
}

// Output DTO (Response)
class DoctorResponse
{
    public function __construct(private Doctor $doctor) {}

    public function toArray(): array
    {
        return [
            'id'         => (string) $this->doctor->getId(),
            'uuid'       => $this->doctor->getUuid(),
            'name'       => $this->doctor->getName(),
            'gender'     => $this->doctor->getGender(),
            'experience' => $this->doctor->getExperience(),
            // ...
        ];
    }
}

Health Check — GET /health

// Response 200:
{
  "status": "ok",
  "checks": {
    "database": "ok",
    "redis": "ok"
  },
  "timestamp": 1748000000
}

// Response 503 (اگر یکی از سرویس‌ها down باشد):
{
  "status": "degraded",
  "checks": {
    "database": "ok",
    "redis": "error"
  },
  "timestamp": 1748000000
}

File Upload — محدودیت‌های مشترک و امنیتی

حداکثر حجم فایل: 5MB
فرمت‌های مجاز: image/jpeg, image/png, image/webp
هدرهای الزامی:
  Content-Type: application/octet-stream
  Content-Disposition: file; filename="name.jpg"
  Authorization: Bearer {token}

⚠ اعتبارسنجی امنیتی فایل — بررسی محتوا، نه header

// src/Shared/Service/FileValidatorService.php
class FileValidatorService
{
    // Magic bytes برای تشخیص واقعی نوع فایل
    private const ALLOWED_SIGNATURES = [
        'image/jpeg' => ["\xFF\xD8\xFF"],
        'image/png'  => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"],
        'image/webp' => ["RIFF"],
    ];

    public function validate(string $binaryContent, string $claimedFilename): void
    {
        // ۱. بررسی حجم
        if (strlen($binaryContent) > 5 * 1024 * 1024) {
            throw new AppException(ErrorCodes::ERR_FILE_002);
        }

        // ۲. بررسی magic bytes — نه MIME از header
        $detected = false;
        foreach (self::ALLOWED_SIGNATURES as $mime => $signatures) {
            foreach ($signatures as $sig) {
                if (str_starts_with($binaryContent, $sig)) {
                    $detected = true;
                    break 2;
                }
            }
        }
        if (!$detected) {
            throw new AppException(ErrorCodes::ERR_FILE_001);
        }

        // ۳. Sanitize filename — جلوگیری از path traversal
        $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', basename($claimedFilename));
        if (empty($safeName) || str_contains($safeName, '..')) {
            throw new AppException(ErrorCodes::ERR_FILE_001);
        }

        // ۴. پسوند باید با content مطابقت داشته باشد
        $ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION));
        if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp'], true)) {
            throw new AppException(ErrorCodes::ERR_FILE_001);
        }
    }
}

Swagger — نحوه استفاده در Controller

use OpenApi\Attributes as OA;

#[OA\Tag(name: 'Doctor')]
class DoctorController extends BaseController
{
    #[OA\Get(
        path: '/api/v1/doctor/{uuid}',
        summary: 'دریافت پروفایل دکتر',
        security: [['bearerAuth' => []]],
        parameters: [
            new OA\Parameter(name: 'uuid', in: 'path', required: true,
                schema: new OA\Schema(type: 'string', format: 'uuid'))
        ],
        responses: [
            new OA\Response(response: 200, description: 'پروفایل کامل دکتر'),
            new OA\Response(response: 404, description: 'دکتر یافت نشد'),
        ]
    )]
    #[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
    public function show(string $uuid): JsonResponse { ... }
}

متغیرهای محیطی (.env)

APP_ENV=dev
APP_SECRET=your-secret-key

DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0"

REDIS_URL=redis://redis:6379

# Queue (از Redis استفاده می‌کند)
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages

JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=your-passphrase

# Refresh Token TTL (ثانیه) — 30 روز
REFRESH_TOKEN_TTL=2592000

OTP_TTL=1200

# SMS Providers
KAVENEGAR_API_KEY=your-key
RANGINEH_API_KEY=your-key
SMS_PROVIDER=kavenegar

# File Upload
MAX_FILE_SIZE_BYTES=5242880