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.
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
# editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[{compose.yaml,compose.*.yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
###> symfony/framework-bundle ###
|
||||||
|
APP_ENV=dev
|
||||||
|
APP_SECRET=clinic_pro_secret_change_in_prod
|
||||||
|
APP_SHARE_DIR=var/share
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
|
###> symfony/routing ###
|
||||||
|
DEFAULT_URI=https://clinic-pro.ddev.site
|
||||||
|
###< symfony/routing ###
|
||||||
|
|
||||||
|
###> doctrine/doctrine-bundle ###
|
||||||
|
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0&charset=utf8mb4"
|
||||||
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
|
###> lexik/jwt-authentication-bundle ###
|
||||||
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
|
JWT_PASSPHRASE=5778180ab122fbb3253d84f4137dbc1672109bab9ad051d3d40fb1c2be3e242d
|
||||||
|
###< lexik/jwt-authentication-bundle ###
|
||||||
|
|
||||||
|
###> nelmio/cors-bundle ###
|
||||||
|
CORS_ALLOW_ORIGIN='^https?://(clinic-pro\.ddev\.site|localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
||||||
|
###< nelmio/cors-bundle ###
|
||||||
|
|
||||||
|
###> symfony/messenger ###
|
||||||
|
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
|
||||||
|
###< symfony/messenger ###
|
||||||
|
|
||||||
|
###> Redis ###
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
###< Redis ###
|
||||||
|
|
||||||
|
###> Auth ###
|
||||||
|
REFRESH_TOKEN_TTL=2592000
|
||||||
|
OTP_TTL=1200
|
||||||
|
###< Auth ###
|
||||||
|
|
||||||
|
###> SMS ###
|
||||||
|
KAVENEGAR_API_KEY=change_me
|
||||||
|
RANGINEH_API_KEY=change_me
|
||||||
|
SMS_PROVIDER=kavenegar
|
||||||
|
###< SMS ###
|
||||||
|
|
||||||
|
###> File Upload ###
|
||||||
|
MAX_FILE_SIZE_BYTES=5242880
|
||||||
|
UPLOAD_DIR=var/uploads
|
||||||
|
###< File Upload ###
|
||||||
|
|
||||||
|
###> Payment ###
|
||||||
|
ALLOWED_FRONTEND_HOSTS=clinic-pro.ddev.site,localhost
|
||||||
|
###< Payment ###
|
||||||
|
|
||||||
|
# Payment
|
||||||
|
MELLAT_TERMINAL_ID=00000000
|
||||||
|
MELLAT_USERNAME=testuser
|
||||||
|
MELLAT_PASSWORD=testpass
|
||||||
|
SEP_TERMINAL_ID=00000000
|
||||||
|
APP_BASE_URL=https://clinic-pro.ddev.site
|
||||||
|
|
||||||
|
# SMS
|
||||||
|
KAVENEGAR_API_KEY=test_key
|
||||||
|
KAVENEGAR_SENDER=1000596446
|
||||||
|
RANGINEH_API_KEY=test_key
|
||||||
|
RANGINEH_SENDER=3000
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
###> symfony/framework-bundle ###
|
||||||
|
APP_SECRET=42f34156531bad221462ff02bd43f42b
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
###> symfony/framework-bundle ###
|
||||||
|
APP_ENV=prod
|
||||||
|
# IMPORTANT: generate a strong random secret for production:
|
||||||
|
# php -r "echo bin2hex(random_bytes(32));"
|
||||||
|
APP_SECRET=CHANGE_ME_STRONG_RANDOM_32_CHARS
|
||||||
|
APP_SHARE_DIR=var/share
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
|
###> symfony/routing ###
|
||||||
|
DEFAULT_URI=https://your-domain.com
|
||||||
|
###< symfony/routing ###
|
||||||
|
|
||||||
|
###> doctrine/doctrine-bundle ###
|
||||||
|
DATABASE_URL="mysql://user:CHANGE_ME@db:3306/clinic_pro?serverVersion=8.0&charset=utf8mb4"
|
||||||
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
|
###> lexik/jwt-authentication-bundle ###
|
||||||
|
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||||
|
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
JWT_PASSPHRASE=CHANGE_ME_STRONG_PASSPHRASE
|
||||||
|
###< lexik/jwt-authentication-bundle ###
|
||||||
|
|
||||||
|
###> nelmio/cors-bundle ###
|
||||||
|
CORS_ALLOW_ORIGIN='^https://your-domain\.com$'
|
||||||
|
###< nelmio/cors-bundle ###
|
||||||
|
|
||||||
|
###> symfony/messenger ###
|
||||||
|
MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages
|
||||||
|
###< symfony/messenger ###
|
||||||
|
|
||||||
|
###> Redis ###
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
###< Redis ###
|
||||||
|
|
||||||
|
###> Auth ###
|
||||||
|
REFRESH_TOKEN_TTL=2592000
|
||||||
|
OTP_TTL=1200
|
||||||
|
###< Auth ###
|
||||||
|
|
||||||
|
###> SMS ###
|
||||||
|
KAVENEGAR_API_KEY=CHANGE_ME
|
||||||
|
KAVENEGAR_SENDER=CHANGE_ME
|
||||||
|
RANGINEH_API_KEY=CHANGE_ME
|
||||||
|
RANGINEH_SENDER=CHANGE_ME
|
||||||
|
SMS_PROVIDER=kavenegar
|
||||||
|
###< SMS ###
|
||||||
|
|
||||||
|
###> File Upload ###
|
||||||
|
MAX_FILE_SIZE_BYTES=5242880
|
||||||
|
UPLOAD_DIR=var/uploads
|
||||||
|
###< File Upload ###
|
||||||
|
|
||||||
|
###> Payment ###
|
||||||
|
ALLOWED_FRONTEND_HOSTS=your-domain.com
|
||||||
|
APP_BASE_URL=https://your-domain.com
|
||||||
|
MELLAT_TERMINAL_ID=CHANGE_ME
|
||||||
|
MELLAT_USERNAME=CHANGE_ME
|
||||||
|
MELLAT_PASSWORD=CHANGE_ME
|
||||||
|
SEP_TERMINAL_ID=CHANGE_ME
|
||||||
|
###< Payment ###
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# define your env variables for the test env here
|
||||||
|
KERNEL_CLASS='App\Kernel'
|
||||||
|
APP_SECRET='$ecretf0rt3st'
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
|
||||||
|
###> symfony/framework-bundle ###
|
||||||
|
/.env.local
|
||||||
|
/.env.local.php
|
||||||
|
/.env.*.local
|
||||||
|
/config/secrets/prod/prod.decrypt.private.php
|
||||||
|
/public/bundles/
|
||||||
|
/var/
|
||||||
|
/vendor/
|
||||||
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
|
###> lexik/jwt-authentication-bundle ###
|
||||||
|
/config/jwt/*.pem
|
||||||
|
###< lexik/jwt-authentication-bundle ###
|
||||||
|
|
||||||
|
###> phpunit/phpunit ###
|
||||||
|
/phpunit.xml
|
||||||
|
/.phpunit.cache/
|
||||||
|
###< phpunit/phpunit ###
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# ddev local environment
|
||||||
|
/.ddev/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
/var/log/
|
||||||
|
|
||||||
|
# Uploaded files
|
||||||
|
/public/uploads/
|
||||||
|
|
||||||
|
# Composer
|
||||||
|
/composer.phar
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
Copyright (c) Fabien Potencier
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished
|
||||||
|
to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Kernel;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
|
||||||
|
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||||
|
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||||
|
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||||
|
|
||||||
|
return function (array $context) {
|
||||||
|
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||||
|
|
||||||
|
return new Application($kernel);
|
||||||
|
};
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
require dirname(__DIR__).'/vendor/phpunit/phpunit/phpunit';
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
services:
|
||||||
|
###> doctrine/doctrine-bundle ###
|
||||||
|
database:
|
||||||
|
ports:
|
||||||
|
- "5432"
|
||||||
|
###< doctrine/doctrine-bundle ###
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
|
||||||
|
services:
|
||||||
|
###> doctrine/doctrine-bundle ###
|
||||||
|
database:
|
||||||
|
image: postgres:${POSTGRES_VERSION:-16}-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-app}
|
||||||
|
# You should definitely change the password in production
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-app}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 60s
|
||||||
|
volumes:
|
||||||
|
- database_data:/var/lib/postgresql/data:rw
|
||||||
|
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
|
||||||
|
# - ./docker/db/data:/var/lib/postgresql/data:rw
|
||||||
|
###< doctrine/doctrine-bundle ###
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
###> doctrine/doctrine-bundle ###
|
||||||
|
database_data:
|
||||||
|
###< doctrine/doctrine-bundle ###
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"name": "symfony/skeleton",
|
||||||
|
"type": "project",
|
||||||
|
"license": "MIT",
|
||||||
|
"description": "A minimal Symfony project recommended to create bare bones applications",
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"prefer-stable": true,
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.2",
|
||||||
|
"ext-ctype": "*",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"doctrine/doctrine-bundle": "*",
|
||||||
|
"doctrine/doctrine-migrations-bundle": "*",
|
||||||
|
"doctrine/orm": "^3.6",
|
||||||
|
"lexik/jwt-authentication-bundle": "*",
|
||||||
|
"nelmio/api-doc-bundle": "*",
|
||||||
|
"nelmio/cors-bundle": "*",
|
||||||
|
"symfony/asset": "7.4.*",
|
||||||
|
"symfony/cache": "7.4.*",
|
||||||
|
"symfony/console": "7.4.*",
|
||||||
|
"symfony/dotenv": "7.4.*",
|
||||||
|
"symfony/flex": "^2",
|
||||||
|
"symfony/framework-bundle": "7.4.*",
|
||||||
|
"symfony/http-client": "7.4.*",
|
||||||
|
"symfony/messenger": "7.4.*",
|
||||||
|
"symfony/property-access": "7.4.*",
|
||||||
|
"symfony/property-info": "7.4.*",
|
||||||
|
"symfony/rate-limiter": "7.4.*",
|
||||||
|
"symfony/runtime": "7.4.*",
|
||||||
|
"symfony/security-bundle": "7.4.*",
|
||||||
|
"symfony/serializer": "7.4.*",
|
||||||
|
"symfony/uid": "7.4.*",
|
||||||
|
"symfony/validator": "7.4.*",
|
||||||
|
"symfony/yaml": "7.4.*",
|
||||||
|
"twig/twig": "*",
|
||||||
|
"zircote/swagger-php": "*"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"php-http/discovery": true,
|
||||||
|
"symfony/flex": true,
|
||||||
|
"symfony/runtime": true
|
||||||
|
},
|
||||||
|
"bump-after-update": true,
|
||||||
|
"sort-packages": true
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"replace": {
|
||||||
|
"symfony/polyfill-ctype": "*",
|
||||||
|
"symfony/polyfill-iconv": "*",
|
||||||
|
"symfony/polyfill-php72": "*",
|
||||||
|
"symfony/polyfill-php73": "*",
|
||||||
|
"symfony/polyfill-php74": "*",
|
||||||
|
"symfony/polyfill-php80": "*",
|
||||||
|
"symfony/polyfill-php81": "*",
|
||||||
|
"symfony/polyfill-php82": "*"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"auto-scripts": {
|
||||||
|
"cache:clear": "symfony-cmd",
|
||||||
|
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||||
|
},
|
||||||
|
"post-install-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
"@auto-scripts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"symfony/symfony": "*"
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"symfony": {
|
||||||
|
"allow-contrib": false,
|
||||||
|
"require": "7.4.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^2.2",
|
||||||
|
"phpstan/phpstan-doctrine": "^2.0",
|
||||||
|
"phpstan/phpstan-symfony": "^2.0",
|
||||||
|
"phpunit/phpunit": "^12.5",
|
||||||
|
"symfony/browser-kit": "7.4.*",
|
||||||
|
"symfony/css-selector": "7.4.*",
|
||||||
|
"symfony/debug-bundle": "7.4.*",
|
||||||
|
"symfony/maker-bundle": "^1.67"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+9214
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||||
|
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||||
|
Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||||
|
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
|
||||||
|
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||||
|
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||||
|
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
|
||||||
|
];
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
framework:
|
||||||
|
cache:
|
||||||
|
app: cache.adapter.redis
|
||||||
|
default_redis_provider: '%env(REDIS_URL)%'
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
when@dev:
|
||||||
|
debug:
|
||||||
|
# Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser.
|
||||||
|
# See the "server:dump" command to start a new server.
|
||||||
|
dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
url: '%env(resolve:DATABASE_URL)%'
|
||||||
|
profiling_collect_backtrace: '%kernel.debug%'
|
||||||
|
use_savepoints: true
|
||||||
|
orm:
|
||||||
|
auto_generate_proxy_classes: true
|
||||||
|
enable_lazy_ghost_objects: true
|
||||||
|
report_fields_where_declared: true
|
||||||
|
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||||
|
auto_mapping: true
|
||||||
|
mappings:
|
||||||
|
App:
|
||||||
|
type: attribute
|
||||||
|
is_bundle: false
|
||||||
|
dir: '%kernel.project_dir%/src'
|
||||||
|
prefix: 'App'
|
||||||
|
alias: App
|
||||||
|
controller_resolver:
|
||||||
|
auto_mapping: false
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
doctrine:
|
||||||
|
dbal:
|
||||||
|
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
doctrine:
|
||||||
|
orm:
|
||||||
|
auto_generate_proxy_classes: false
|
||||||
|
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||||
|
query_cache_driver:
|
||||||
|
type: pool
|
||||||
|
pool: doctrine.system_cache_pool
|
||||||
|
result_cache_driver:
|
||||||
|
type: pool
|
||||||
|
pool: doctrine.result_cache_pool
|
||||||
|
|
||||||
|
framework:
|
||||||
|
cache:
|
||||||
|
pools:
|
||||||
|
doctrine.result_cache_pool:
|
||||||
|
adapter: cache.app
|
||||||
|
doctrine.system_cache_pool:
|
||||||
|
adapter: cache.system
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
doctrine_migrations:
|
||||||
|
migrations_paths:
|
||||||
|
# namespace is arbitrary but should be different from App\Migrations
|
||||||
|
# as migrations classes should NOT be autoloaded
|
||||||
|
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||||
|
enable_profiler: false
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||||
|
framework:
|
||||||
|
secret: '%env(APP_SECRET)%'
|
||||||
|
|
||||||
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
|
session: true
|
||||||
|
|
||||||
|
#esi: true
|
||||||
|
#fragments: true
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
test: true
|
||||||
|
session:
|
||||||
|
storage_factory_id: session.storage.factory.mock_file
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
framework:
|
||||||
|
messenger:
|
||||||
|
failure_transport: failed
|
||||||
|
|
||||||
|
transports:
|
||||||
|
async:
|
||||||
|
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||||
|
retry_strategy:
|
||||||
|
max_retries: 3
|
||||||
|
delay: 5000
|
||||||
|
multiplier: 2
|
||||||
|
failed: 'doctrine://default?queue_name=failed'
|
||||||
|
sync: 'sync://'
|
||||||
|
|
||||||
|
routing:
|
||||||
|
'App\Shared\Message\SendSmsMessage': async
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
messenger:
|
||||||
|
transports:
|
||||||
|
async: 'in-memory://'
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
nelmio_cors:
|
||||||
|
defaults:
|
||||||
|
origin_regex: true
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
|
allow_methods: ['GET', 'OPTIONS', 'POST', 'PATCH', 'DELETE']
|
||||||
|
allow_headers: ['Content-Type', 'Authorization', 'X-CSRF-Token', 'Content-Disposition']
|
||||||
|
expose_headers: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset']
|
||||||
|
max_age: 3600
|
||||||
|
allow_credentials: false
|
||||||
|
paths:
|
||||||
|
'^/api/':
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
|
'^/oauth/':
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
|
'^/health':
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
framework:
|
||||||
|
property_info:
|
||||||
|
with_constructor_extractor: true
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
framework:
|
||||||
|
rate_limiter:
|
||||||
|
# OTP send-code: max 5 requests per hour per IP (prevents SMS flood)
|
||||||
|
send_code:
|
||||||
|
policy: 'sliding_window'
|
||||||
|
limit: 5
|
||||||
|
interval: '60 minutes'
|
||||||
|
|
||||||
|
# Login: max 10 attempts per minute per IP (brute force protection)
|
||||||
|
login:
|
||||||
|
policy: 'fixed_window'
|
||||||
|
limit: 10
|
||||||
|
interval: '1 minute'
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
framework:
|
||||||
|
router:
|
||||||
|
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||||
|
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||||
|
default_uri: '%env(DEFAULT_URI)%'
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
framework:
|
||||||
|
router:
|
||||||
|
strict_requirements: null
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
security:
|
||||||
|
password_hashers:
|
||||||
|
App\Auth\Entity\User:
|
||||||
|
algorithm: auto
|
||||||
|
|
||||||
|
providers:
|
||||||
|
app_user_provider:
|
||||||
|
entity:
|
||||||
|
class: App\Auth\Entity\User
|
||||||
|
property: mobileNumber
|
||||||
|
|
||||||
|
firewalls:
|
||||||
|
dev:
|
||||||
|
pattern: ^/(_profiler|_wdt|assets|build)/
|
||||||
|
security: false
|
||||||
|
|
||||||
|
health:
|
||||||
|
pattern: ^/health$
|
||||||
|
security: false
|
||||||
|
|
||||||
|
public_endpoints:
|
||||||
|
pattern: ^/(api/v1/user/(send-code|verify-code|register)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/comments/|api/v1/rate/|api/v1/blogs$)
|
||||||
|
stateless: true
|
||||||
|
security: false
|
||||||
|
|
||||||
|
payment_callback:
|
||||||
|
pattern: ^/api/v1/(payment|subscription-payment)/callback/
|
||||||
|
stateless: true
|
||||||
|
security: false
|
||||||
|
|
||||||
|
api:
|
||||||
|
pattern: ^/(api|oauth)/
|
||||||
|
stateless: true
|
||||||
|
provider: app_user_provider
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Auth\Security\PasswordAuthenticator
|
||||||
|
jwt: ~
|
||||||
|
|
||||||
|
access_control:
|
||||||
|
- { path: ^/health$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/user/send-code, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/user/verify-code, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/user/register, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/user/login, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/appointment-slots, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/comments/, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/rate/, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/blogs$, roles: PUBLIC_ACCESS }
|
||||||
|
- path: '^/api/v1/blog/[^/]+$'
|
||||||
|
methods: [GET]
|
||||||
|
roles: PUBLIC_ACCESS
|
||||||
|
- { path: ^/oauth/token$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/oauth/token/refresh$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/session/token, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/payment/callback/, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/subscription-payment/callback/, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/doc, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/categorys/, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/doctors$, roles: PUBLIC_ACCESS }
|
||||||
|
- path: '^/api/v1/doctor/[^/]+$'
|
||||||
|
methods: [GET]
|
||||||
|
roles: PUBLIC_ACCESS
|
||||||
|
- { path: ^/api/v1/clinic/doctor-list/, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/clinic-pro/doctor-addresses/, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/clinics$, roles: PUBLIC_ACCESS }
|
||||||
|
- path: '^/api/v1/clinic/[^/]+$'
|
||||||
|
methods: [GET]
|
||||||
|
roles: PUBLIC_ACCESS
|
||||||
|
- { path: ^/api/v1/user/\d+$, methods: [DELETE], roles: ROLE_ADMIN }
|
||||||
|
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
- { path: ^/oauth/userinfo, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
- { path: ^/oauth/logout, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
security:
|
||||||
|
password_hashers:
|
||||||
|
App\Auth\Entity\User:
|
||||||
|
algorithm: auto
|
||||||
|
cost: 4
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
framework:
|
||||||
|
validation:
|
||||||
|
# Enables validator auto-mapping support.
|
||||||
|
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
||||||
|
#auto_mapping:
|
||||||
|
# App\Entity\: []
|
||||||
|
|
||||||
|
when@test:
|
||||||
|
framework:
|
||||||
|
validation:
|
||||||
|
not_compromised_password: false
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||||
|
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
|
||||||
|
|
||||||
|
# This file is the entry point to configure the routes of your app.
|
||||||
|
# Methods with the #[Route] attribute are automatically imported.
|
||||||
|
# See also https://symfony.com/doc/current/routing.html
|
||||||
|
|
||||||
|
# To list all registered routes, run the following command:
|
||||||
|
# bin/console debug:router
|
||||||
|
|
||||||
|
controllers:
|
||||||
|
resource: routing.controllers
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
when@dev:
|
||||||
|
_errors:
|
||||||
|
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
||||||
|
prefix: /_error
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
_security_logout:
|
||||||
|
resource: security.route_loader.logout
|
||||||
|
type: service
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
||||||
|
|
||||||
|
# This file is the entry point to configure your own services.
|
||||||
|
# Files in the packages/ subdirectory configure your dependencies.
|
||||||
|
# See also https://symfony.com/doc/current/service_container/import.html
|
||||||
|
|
||||||
|
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||||
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
|
parameters: {}
|
||||||
|
|
||||||
|
services:
|
||||||
|
# default configuration for services in *this* file
|
||||||
|
_defaults:
|
||||||
|
autowire: true # Automatically injects dependencies in your services.
|
||||||
|
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||||
|
|
||||||
|
# makes classes in src/ available to be used as services
|
||||||
|
# this creates a service per class whose id is the fully-qualified class name
|
||||||
|
App\:
|
||||||
|
resource: '../src/'
|
||||||
|
|
||||||
|
App\Doctor\Controller\DoctorController:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
|
||||||
|
App\Clinic\Controller\ClinicController:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
|
||||||
|
App\Auth\Service\OtpService:
|
||||||
|
arguments:
|
||||||
|
$otpTtl: '%env(int:OTP_TTL)%'
|
||||||
|
$appEnv: '%kernel.environment%'
|
||||||
|
|
||||||
|
App\Auth\Service\TokenService:
|
||||||
|
arguments:
|
||||||
|
$refreshTokenTtl: '%env(int:REFRESH_TOKEN_TTL)%'
|
||||||
|
|
||||||
|
App\Auth\Security\PasswordAuthenticator:
|
||||||
|
arguments:
|
||||||
|
$refreshTokenTtl: '%env(int:REFRESH_TOKEN_TTL)%'
|
||||||
|
$loginLimiter: '@limiter.login'
|
||||||
|
|
||||||
|
App\Auth\Controller\AuthController:
|
||||||
|
arguments:
|
||||||
|
$sendCodeLimiter: '@limiter.send_code'
|
||||||
|
|
||||||
|
App\Payment\Gateway\MellatGateway:
|
||||||
|
arguments:
|
||||||
|
$terminalId: '%env(MELLAT_TERMINAL_ID)%'
|
||||||
|
$username: '%env(MELLAT_USERNAME)%'
|
||||||
|
$password: '%env(MELLAT_PASSWORD)%'
|
||||||
|
|
||||||
|
App\Payment\Gateway\SepGateway:
|
||||||
|
arguments:
|
||||||
|
$terminalId: '%env(SEP_TERMINAL_ID)%'
|
||||||
|
|
||||||
|
App\Payment\Controller\PaymentController:
|
||||||
|
arguments:
|
||||||
|
$appBaseUrl: '%env(APP_BASE_URL)%'
|
||||||
|
$allowedFrontendHosts: '%env(ALLOWED_FRONTEND_HOSTS)%'
|
||||||
|
|
||||||
|
App\Sms\Provider\KavehNegarProvider:
|
||||||
|
arguments:
|
||||||
|
$apiKey: '%env(KAVENEGAR_API_KEY)%'
|
||||||
|
$sender: '%env(KAVENEGAR_SENDER)%'
|
||||||
|
|
||||||
|
App\Sms\Provider\RanginehProvider:
|
||||||
|
arguments:
|
||||||
|
$apiKey: '%env(RANGINEH_API_KEY)%'
|
||||||
|
$sender: '%env(RANGINEH_SENDER)%'
|
||||||
|
|
||||||
|
App\Blog\Controller\BlogController:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
@@ -0,0 +1,823 @@
|
|||||||
|
# Architecture Audit — ClinicPro Symfony 7 Migration
|
||||||
|
|
||||||
|
**تاریخ:** ۱۴۰۵/۰۳/۱۸
|
||||||
|
**بررسیکننده:** Senior Software Architect
|
||||||
|
**نسخه مستند:** ۱.۰
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
پروژه **ClinicPro** یک مهاجرت از Drupal به Symfony 7 است. سیستم یک پلتفرم Multi-tenant نوبتدهی پزشکی است با ۱۷ ماژول و ~۹۵ Endpoint.
|
||||||
|
|
||||||
|
معماری پیشنهادی از نظر انتخاب تکنولوژی مناسب و ساختار پایگاه داده قابل قبول است، اما **بیش از ۳۰ Endpoint فاقد مستندات Response هستند**، Business Logic های کلیدی (پرداخت، محاسبه امتیاز، نوبتدهی) تعریفنشدهاند، و الگوی Entity-Bundle درایتشده از Drupal بدون تطبیق صحیح به Symfony منتقل شده است.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Score (بعد از اصلاحات)
|
||||||
|
|
||||||
|
```
|
||||||
|
Overall Score: 78 / 100 ↑ از 61
|
||||||
|
```
|
||||||
|
|
||||||
|
| بُعد | امتیاز قبل | امتیاز بعد | تغییرات |
|
||||||
|
|------|-----------|-----------|---------|
|
||||||
|
| Scalability | 55/100 | 60/100 | Messenger async، Redis cache پیشبینی شد |
|
||||||
|
| Security | 65/100 | **82/100** | Refresh Token، hash_equals، CORS fix، Security Headers، Audit Log، Open Redirect fix |
|
||||||
|
| Maintainability | 60/100 | 80/100 | Domain-Driven structure، DTO، Error Codes، Response format یکپارچه |
|
||||||
|
| Performance | 58/100 | 65/100 | Eager loading، Redis cache plan |
|
||||||
|
| Reliability | 55/100 | 72/100 | Circuit Breaker، Idempotency، Payment flow کامل |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱. تطابق معماری با PRD
|
||||||
|
|
||||||
|
### ماژولهای شناساییشده در PRD
|
||||||
|
|
||||||
|
| # | ماژول | Endpoint ها | وضعیت در معماری |
|
||||||
|
|---|--------|------------|-----------------|
|
||||||
|
| ۱ | Authentication (احراز هویت) | ۸ | ✅ تعریف شده |
|
||||||
|
| ۲ | User Profile (پروفایل کاربر) | ۴ | ✅ تعریف شده |
|
||||||
|
| ۳ | Blog (وبلاگ) | ۷ | ✅ تعریف شده |
|
||||||
|
| ۴ | Doctor (دکتر) | ۱۰ | ✅ تعریف شده |
|
||||||
|
| ۵ | Clinic (کلینیک) | ۷ | ✅ تعریف شده |
|
||||||
|
| ۶ | Agent (نماینده) | ۳ | ✅ تعریف شده |
|
||||||
|
| ۷ | Categories (دستهبندیها) | ۱۰ | ✅ تعریف شده |
|
||||||
|
| ۸ | Doctor Insurance (بیمه دکتر) | ۴ | ✅ تعریف شده |
|
||||||
|
| ۹ | Appointment Settings (تنظیمات نوبت) | ۱۱ | ✅ تعریف شده |
|
||||||
|
| ۱۰ | Appointment (نوبتدهی) | ۴ | ✅ تعریف شده |
|
||||||
|
| ۱۱ | Payment (پرداخت) | ۳ | ✅ تعریف شده |
|
||||||
|
| ۱۲ | Rating & Comments (امتیاز و نظرات) | ۱۲ | ✅ تعریف شده |
|
||||||
|
| ۱۳ | Likes (لایک) | ۲ | ✅ تعریف شده |
|
||||||
|
| ۱۴ | Secretary (منشی) | ۵ | ✅ تعریف شده |
|
||||||
|
| ۱۵ | Representation Dashboard (داشبورد) | ۵ | ✅ تعریف شده |
|
||||||
|
| ۱۶ | SMS | — | ⚠️ بدون task.md |
|
||||||
|
| ۱۷ | File Upload | — | ⚠️ Embedded در سایر ماژولها |
|
||||||
|
| — | Subscription Payments | — | ❌ Endpoint تعریف نشده |
|
||||||
|
| — | Health Check | — | ❌ اصلاً وجود ندارد |
|
||||||
|
| — | Refresh Token | — | ❌ وجود ندارد |
|
||||||
|
|
||||||
|
### موارد پوششدادهنشده از PRD
|
||||||
|
|
||||||
|
- **Subscription Payment Endpoints** — جدول `subscription_payments` وجود دارد اما هیچ endpoint برای مدیریت آن نیست
|
||||||
|
- **City-specific management** — City bundle دارای ۸+ فیلد توسعهیافته (domain، SEO، footer) است که هیچ endpoint ای برای مدیریت آنها نیست
|
||||||
|
- **Comment Nesting** — فیلد `field_parent` در DB وجود دارد اما در API تعریف نشده
|
||||||
|
- **Appointment Cancellation Flow** — لغو نوبت و refund پرداخت مستند نشده
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۲. تحلیل معماری فعلی
|
||||||
|
|
||||||
|
### Scalability
|
||||||
|
|
||||||
|
**نقاط قوت:**
|
||||||
|
- UUID در public API — امکان sharding در آینده را حفظ میکند
|
||||||
|
- Redis برای OTP — scalable و stateless
|
||||||
|
|
||||||
|
**نقاط ضعف:**
|
||||||
|
- Single MySQL instance — هیچ read replica تعریف نشده؛ با رشد کاربر، query های سنگین لیست دکتر/کلینیک با فیلتر چندگانه روی master اجرا میشوند
|
||||||
|
- JSON columns بدون index — فیلدهایی مثل `weekly_schedules.setting` و `appointments.slot` با JSON ذخیره میشوند اما قابل index نیستند؛ جستجو روی آنها Full Table Scan است
|
||||||
|
- هیچ cache strategy فراتر از OTP وجود ندارد — لیست دستهبندیها، تخصصها، استانها با هر request از DB خوانده میشوند
|
||||||
|
|
||||||
|
### Maintainability
|
||||||
|
|
||||||
|
**نقاط قوت:**
|
||||||
|
- Task decomposition منطقی با dependency graph مشخص
|
||||||
|
- UUID، timestamps، naming convention یکپارچه
|
||||||
|
|
||||||
|
**نقاط ضعف:**
|
||||||
|
- ۳۰+ endpoint بدون response schema — هر developer میتواند خروجی متفاوتی بسازد
|
||||||
|
- نامگذاری ناسازگار: `img` در doctor، `images_clinic` در clinic، `field_image` در blog
|
||||||
|
- Business logic (فرمول rating، منطق free_turn) مستند نشده
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
**نقاط قوت:**
|
||||||
|
- OTP-based login — بدون password در پیام
|
||||||
|
- JWT با TTL مشخص (3600s)
|
||||||
|
- Rate limiting با Redis (50 req/hr per IP، 30 req/hr per mobile)
|
||||||
|
- MIME type validation در file upload
|
||||||
|
|
||||||
|
**نقاط ضعف:**
|
||||||
|
- بدون Refresh Token — کاربر هر ساعت باید re-login کند یا OTP مجدد دریافت کند
|
||||||
|
- CSRF inconsistent — در بعضی endpoint ها الزامی، در بعضی خیر
|
||||||
|
- بدون audit log — هیچجا ثبت نمیشود چه کسی چه تغییری داده
|
||||||
|
- Secrets در `.env` — برای production باید Vault یا محیط CI/CD مدیریت شود
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
**نقاط قوت:**
|
||||||
|
- Index های مناسب روی uuid، mobile_number، created_at، doctor_id + start_time
|
||||||
|
- Redis برای OTP (نه DB)
|
||||||
|
|
||||||
|
**نقاط ضعف:**
|
||||||
|
- N+1 Query احتمالی — response دکتر شامل specialties، expertise، address، state، city است؛ بدون eager loading، هر doctor یک batch جداگانه query ایجاد میکند
|
||||||
|
- بدون Query Result Cache — categories، lookups با هر request از DB خوانده میشوند
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
|
||||||
|
**نقاط قوت:**
|
||||||
|
- Status machine واضح برای appointments و payments
|
||||||
|
- Soft delete با deleted_at
|
||||||
|
|
||||||
|
**نقاط ضعف:**
|
||||||
|
- Payment gateway single point of failure — اگر Mellat یا SEP در دسترس نباشد، سیستم نوبتدهی متوقف میشود
|
||||||
|
- SMS sync — اگر KavehNegar/Rangineh fail شود، OTP ارسال نمیشود و کاربر مسدود میشود
|
||||||
|
- بدون Circuit Breaker برای external services
|
||||||
|
|
||||||
|
### Testability
|
||||||
|
|
||||||
|
**نقاط ضعف:**
|
||||||
|
- هیچ اشارهای به test strategy نشده
|
||||||
|
- Business logic در کجا؟ اگر در Controller باشد، unit test غیرممکن میشود
|
||||||
|
- هیچ fixture/seeder برای category data (۳۱ استان + شهرها) تعریف نشده
|
||||||
|
|
||||||
|
### Observability
|
||||||
|
|
||||||
|
**ضعف کامل:**
|
||||||
|
- بدون logging strategy
|
||||||
|
- بدون health check endpoint
|
||||||
|
- بدون metrics (Prometheus/Grafana)
|
||||||
|
- بدون distributed tracing
|
||||||
|
- بدون alerting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۳. تحلیل Design Patterns
|
||||||
|
|
||||||
|
### Pattern هایی که استفاده شدهاند
|
||||||
|
|
||||||
|
| Pattern | کجا | ارزیابی |
|
||||||
|
|---------|-----|---------|
|
||||||
|
| Repository Pattern | ضمنی از Doctrine | ✅ درست اما باید صریح تعریف شود |
|
||||||
|
| DTO | اشاره نشده | ❌ باید اضافه شود |
|
||||||
|
| Strategy | payment gateways (Mellat/SEP) | ⚠️ تعریف نشده اما ضروری است |
|
||||||
|
| Observer/Event | SMS async | ❌ وجود ندارد — باید با Symfony Messenger پیاده شود |
|
||||||
|
| Status Machine | appointments/payments | ✅ خوب تعریف شده |
|
||||||
|
| Multi-tenant (Representation) | داشبورد | ✅ معقول |
|
||||||
|
|
||||||
|
### Anti-Pattern هایی که مشاهده میشوند
|
||||||
|
|
||||||
|
**۱. God Table**
|
||||||
|
جدول `categories` شامل ۷ نوع کاملاً متفاوت است (state, city, specialty, insurance, tag, ...). این Drupal-specific است و در Symfony باید به STI یا جداول جداگانه تبدیل شود.
|
||||||
|
|
||||||
|
**۲. Anemic Domain Model**
|
||||||
|
Entity ها فقط data holder هستند. هیچ Business Logic در آنها نیست. اگر همه منطق در Controller باشد، Fat Controller anti-pattern اجتنابناپذیر است.
|
||||||
|
|
||||||
|
**۳. Magic Field Names (Drupal Legacy)**
|
||||||
|
`field_starts` (نه `field_stars`) یک Drupal bug است که عیناً کپی شده. در Symfony باید در mapping layer تبدیل شود، نه مستقیم در Entity.
|
||||||
|
|
||||||
|
**۴. Implicit API Contract**
|
||||||
|
هیچ DTO برای Input/Output تعریف نشده. هر Controller میتواند هر فرمتی برگرداند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۴. تحلیل Domain Design
|
||||||
|
|
||||||
|
### Domain Model ها
|
||||||
|
|
||||||
|
| Domain | Entity ها | وضعیت |
|
||||||
|
|--------|----------|--------|
|
||||||
|
| Identity | User | ✅ خوب — uuid، mobile، realname، roles |
|
||||||
|
| Medical | Doctor، Clinic، DoctorAddress | ✅ معقول |
|
||||||
|
| Scheduling | WeeklySchedule، DateOverride، Holiday | ✅ خوب |
|
||||||
|
| Booking | Appointment، Slot | ⚠️ فلوی کامل مستند نشده |
|
||||||
|
| Financial | Payment، SubscriptionPayment | ⚠️ subscription endpoints مفقود |
|
||||||
|
| Community | Rating، Comment، Like | ✅ ساختار خوب |
|
||||||
|
| Catalog | Category (god table) | ❌ باید refactor شود |
|
||||||
|
| Tenancy | Representation، Agent | ✅ معقول |
|
||||||
|
|
||||||
|
### Bounded Context ها
|
||||||
|
|
||||||
|
مشکل اصلی: **Bounded Context های صریح تعریف نشدهاند.**
|
||||||
|
|
||||||
|
در Drupal، همه چیز در یک entity type است (`clinic_pro`). در Symfony باید مرزهای مشخص بین:
|
||||||
|
- **Identity Context** (User، Auth، OTP)
|
||||||
|
- **Clinical Context** (Doctor، Clinic، Address)
|
||||||
|
- **Scheduling Context** (WeeklySchedule، Appointment)
|
||||||
|
- **Financial Context** (Payment، Subscription)
|
||||||
|
- **Community Context** (Rating، Comment، Like)
|
||||||
|
- **Catalog Context** (Categories، Lookups)
|
||||||
|
- **Tenant Context** (Representation، Agent)
|
||||||
|
|
||||||
|
### Separation of Concerns
|
||||||
|
|
||||||
|
**مشکل:** در هیچجا تعریف نشده Business Logic کجا قرار میگیرد:
|
||||||
|
- محاسبه `free_turn` — Controller؟ Service؟ Entity؟
|
||||||
|
- محاسبه `experience` از `activity_time` — کجا؟
|
||||||
|
- فرمول rating — کجا؟
|
||||||
|
|
||||||
|
بدون تعریف صریح این، هر developer به سلیقه خود عمل میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۵. تحلیل Database
|
||||||
|
|
||||||
|
### جداول شناساییشده (۳۲ جدول)
|
||||||
|
|
||||||
|
**User Management:**
|
||||||
|
- `users` — uuid، mobile، password، realname، roles (JSON)، status (TINYINT)، created_at/updated_at (INT)
|
||||||
|
- `user_profiles` — سوابق پزشکی، آلرژی، دارو، جراحی
|
||||||
|
|
||||||
|
**Clinical Entities:**
|
||||||
|
- `doctors`، `doctor_addresses`، `doctor_specialties`، `doctor_services`، `doctor_states`، `doctor_cities`
|
||||||
|
- `clinics`، `clinic_doctors`، `clinic_specialties`، `clinic_services`، `clinic_insurances`، `clinic_images`
|
||||||
|
- `representations`، `doctor_secretaries`
|
||||||
|
|
||||||
|
**Scheduling & Booking:**
|
||||||
|
- `weekly_schedules`، `date_overrides`، `holidays`، `appointments`
|
||||||
|
|
||||||
|
**Financial:**
|
||||||
|
- `payments`، `subscription_payments`، `doctor_insurance`
|
||||||
|
|
||||||
|
**Content & Community:**
|
||||||
|
- `blogs`، `ratings`، `comments`، `likes`
|
||||||
|
|
||||||
|
**Catalog:**
|
||||||
|
- `categories` (god table با bundle field)
|
||||||
|
|
||||||
|
**System:**
|
||||||
|
- `files`، `sms_logs`
|
||||||
|
|
||||||
|
### مشکلات Database
|
||||||
|
|
||||||
|
**۱. God Table: categories**
|
||||||
|
```sql
|
||||||
|
-- یک جدول برای ۷ نوع کاملاً متفاوت:
|
||||||
|
SELECT * FROM categories WHERE bundle = 'state';
|
||||||
|
SELECT * FROM categories WHERE bundle = 'city';
|
||||||
|
SELECT * FROM categories WHERE bundle = 'specially_doctor';
|
||||||
|
-- ...
|
||||||
|
```
|
||||||
|
پیشنهاد: STI با Doctrine Inheritance یا جداول جداگانه برای هر نوع
|
||||||
|
|
||||||
|
**۲. JSON Columns بدون Index**
|
||||||
|
```sql
|
||||||
|
-- weekly_schedules.setting → JSON (7-day schedule)
|
||||||
|
-- appointments.slot → JSON (time، duration، location_id)
|
||||||
|
-- doctor_secretaries.permission → JSON (undefined structure)
|
||||||
|
```
|
||||||
|
این فیلدها قابل index نیستند. جستجو روی آنها Full Table Scan است.
|
||||||
|
|
||||||
|
**۳. Timestamp به عنوان INT**
|
||||||
|
تمام `created_at`/`updated_at` به صورت Unix timestamp (INT) ذخیره میشوند.
|
||||||
|
این درست اما مستعد اشتباه است — باید در همه جا consistent باشد.
|
||||||
|
|
||||||
|
**۴. Bottleneck احتمالی**
|
||||||
|
- `appointments` جدول داغ است (read/write زیاد) — index composite روی `(doctor_id, start_time)` لازم است
|
||||||
|
- `ratings` باید aggregate view داشته باشد برای `average_rate` تا N+1 نشود
|
||||||
|
|
||||||
|
### ایندکسهای مناسب
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- اضافه کردن این ایندکسها توصیه میشود:
|
||||||
|
CREATE INDEX idx_appointments_doctor_time ON appointments(doctor_id, start_time);
|
||||||
|
CREATE INDEX idx_appointments_status ON appointments(status);
|
||||||
|
CREATE INDEX idx_ratings_doctor ON ratings(doctor_id);
|
||||||
|
CREATE INDEX idx_comments_doctor_approved ON comments(doctor_id, approved);
|
||||||
|
CREATE INDEX idx_categories_bundle ON categories(bundle);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۶. تحلیل API Design
|
||||||
|
|
||||||
|
### نقاط قوت
|
||||||
|
|
||||||
|
- ✅ Versioning با `/api/v1/` در URL
|
||||||
|
- ✅ UUID در public endpoints
|
||||||
|
- ✅ Pagination استاندارد (`page`، `limit`، `totalRecords`، `totalPages`)
|
||||||
|
- ✅ HTTP methods صحیح (GET/POST/PATCH/DELETE)
|
||||||
|
- ✅ Bearer token authentication
|
||||||
|
|
||||||
|
### نقاط ضعف
|
||||||
|
|
||||||
|
**۱. Naming Convention ناسازگار**
|
||||||
|
|
||||||
|
| Endpoint | فیلد | درستتر |
|
||||||
|
|----------|------|---------|
|
||||||
|
| GET /doctor | `img` | `images` |
|
||||||
|
| GET /clinic | `images_clinic` | `images` |
|
||||||
|
| GET /clinic | `phone_number` | `phoneNumber` یا `phone` |
|
||||||
|
| POST categories | `/api/v1/categorys/` | `/api/v1/categories/` (Drupal typo کپی شده) |
|
||||||
|
|
||||||
|
**۲. Error Format استاندارد وجود ندارد**
|
||||||
|
هیچجا فرمت خطا تعریف نشده. کلاینت نمیداند چه انتظاری داشته باشد.
|
||||||
|
|
||||||
|
**۳. Request→DB Field Mapping مستند نشده**
|
||||||
|
|
||||||
|
| نام در Request | نام در DB |
|
||||||
|
|---------------|---------|
|
||||||
|
| `correct_diagnosis` | `accuracy_of_diagnosis` |
|
||||||
|
| `doctor_skill` | `doctor_expertise` |
|
||||||
|
| `behavior_doctor` | `doctor_behavior` |
|
||||||
|
| `office_cleaning` | `clinic_cleanliness` |
|
||||||
|
| `time_in_office` | `waiting_time_at_clinic` |
|
||||||
|
|
||||||
|
این mapping در هیچ لایهای صریح تعریف نشده.
|
||||||
|
|
||||||
|
**۴. بدون Response Schema برای ۳۰+ Endpoint**
|
||||||
|
عبارت "ساختار پاسخ مستند نشده" در بیش از ۳۰ endpoint تکرار شده.
|
||||||
|
|
||||||
|
**۵. Timestamp فرمت ناسازگار**
|
||||||
|
بعضی response ها timestamp را string برمیگردانند (`"activity_time": "1107808200"`)، بعضی integer. استانداردی وجود ندارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۷. تحلیل امنیت
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
| مورد | وضعیت | ریسک |
|
||||||
|
|------|--------|-------|
|
||||||
|
| OTP via SMS | ✅ | Low |
|
||||||
|
| JWT (3600s TTL) | ✅ | Low |
|
||||||
|
| Refresh Token | ❌ وجود ندارد | Medium — کاربر هر ساعت باید re-auth کند |
|
||||||
|
| Password Hashing | ✅ bcrypt | Low |
|
||||||
|
| Mobile as Username | ✅ | Low |
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
| مورد | وضعیت | ریسک |
|
||||||
|
|------|--------|-------|
|
||||||
|
| Role-based (authenticated, doctor, admin) | ✅ | Low |
|
||||||
|
| Owner check در PATCH | ✅ | Low |
|
||||||
|
| Admin-only endpoints | ✅ | Low |
|
||||||
|
| Secretary permissions | ⚠️ JSON بدون schema | High — هر implementer میتواند اشتباه implement کند |
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
| مورد | وضعیت | ریسک |
|
||||||
|
|------|--------|-------|
|
||||||
|
| 50 req/hr per IP | ✅ | Low |
|
||||||
|
| 30 req/hr per mobile | ✅ | Low |
|
||||||
|
| OTP attempt limiting | ⚠️ نامشخص | Medium |
|
||||||
|
| No rate limit header در response | ❌ | Low — UX ضعیف |
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
| مورد | وضعیت | ریسک |
|
||||||
|
|------|--------|-------|
|
||||||
|
| CSRF Token | ⚠️ Inconsistent | Medium |
|
||||||
|
| MIME validation در upload | ✅ | Low |
|
||||||
|
| SQL Injection | ✅ Doctrine ORM | Low |
|
||||||
|
| XSS | ⚠️ تعریف نشده | Medium — JSON response، اما اگر HTML render شود |
|
||||||
|
| File size limit | ⚠️ تعریف نشده | Medium — DoS از طریق بارگذاری فایل بزرگ |
|
||||||
|
|
||||||
|
### Secrets Management
|
||||||
|
|
||||||
|
| مورد | وضعیت | ریسک |
|
||||||
|
|------|--------|-------|
|
||||||
|
| JWT keys در فایلسیستم | ⚠️ | Medium برای production |
|
||||||
|
| DB credentials در .env | ⚠️ | Medium برای production |
|
||||||
|
| SMS API keys | ⚠️ در .env | Medium |
|
||||||
|
| Dev OTP code ثابت (12345) | ⚠️ | Low اگر فقط در dev باشد |
|
||||||
|
|
||||||
|
### Logging Security
|
||||||
|
|
||||||
|
❌ **هیچ logging strategy تعریف نشده.** موارد زیر باید log شوند:
|
||||||
|
- تلاشهای ناموفق OTP
|
||||||
|
- تغییر role کاربر
|
||||||
|
- حذف entity ها
|
||||||
|
- payment transactions
|
||||||
|
- دسترسیهای رد شده
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۸. تحلیل مقیاسپذیری
|
||||||
|
|
||||||
|
### با ۱۰,۰۰۰ کاربر — مشکل جدی نیست
|
||||||
|
|
||||||
|
معماری فعلی این تعداد را handle میکند با:
|
||||||
|
- یک MySQL server
|
||||||
|
- یک Redis instance
|
||||||
|
- یک PHP-FPM instance
|
||||||
|
|
||||||
|
### با ۱۰۰,۰۰۰ کاربر — مشکلات شروع میشوند
|
||||||
|
|
||||||
|
| مشکل | علت | راهحل |
|
||||||
|
|------|-----|---------|
|
||||||
|
| لیست دکتر با فیلتر کند میشود | Full scan روی JSON columns | Read replica + Elasticsearch برای جستجو |
|
||||||
|
| Categories هر بار از DB | بدون cache | Redis cache با TTL=300s |
|
||||||
|
| SMS در صف میماند | Sync call | Symfony Messenger + Queue |
|
||||||
|
| JWT validation سنگین | هر request decode میشود | Redis token blacklist |
|
||||||
|
|
||||||
|
### با ۱,۰۰۰,۰۰۰ کاربر — نیاز به Refactoring اساسی
|
||||||
|
|
||||||
|
| سرویس | مشکل | راهحل |
|
||||||
|
|-------|------|---------|
|
||||||
|
| Appointments | Hot table — write contention | Sharding بر اساس doctor_id |
|
||||||
|
| Search | MySQL full-text کافی نیست | Elasticsearch |
|
||||||
|
| File Upload | Local filesystem | S3-compatible object storage |
|
||||||
|
| SMS | Single provider | Multi-provider با queue |
|
||||||
|
| Auth | Stateless JWT کافی است | Redis session store برای blacklist |
|
||||||
|
|
||||||
|
**Microservice یا Modular Monolith؟**
|
||||||
|
|
||||||
|
در این مرحله: **Modular Monolith** توصیه میشود.
|
||||||
|
|
||||||
|
دلایل:
|
||||||
|
- تیم کوچک
|
||||||
|
- Domain boundaries هنوز در حال تثبیت
|
||||||
|
- Microservice overhead (distributed tracing، service mesh، network latency) در این مرحله ارزشش را ندارد
|
||||||
|
|
||||||
|
آینده (بعد از ۱۰۰K): **Financial Context** (Payment) و **Notification Context** (SMS) کاندیداهای اول برای جداسازی هستند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۹. تحلیل ساختار پروژه
|
||||||
|
|
||||||
|
### ساختار پیشنهادی فعلی (نامشخص)
|
||||||
|
|
||||||
|
هیچجا ساختار پوشه صریح تعریف نشده. احتمال پیشفرض Symfony:
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
Controller/
|
||||||
|
Entity/
|
||||||
|
Repository/
|
||||||
|
Service/
|
||||||
|
```
|
||||||
|
این ساختار Layer-based است و برای ۱۷ ماژول با ۹۵ endpoint به سرعت به هم میریزد.
|
||||||
|
|
||||||
|
### ساختار توصیهشده — Domain-Driven
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
Doctor/
|
||||||
|
Controller/
|
||||||
|
DoctorController.php
|
||||||
|
Entity/
|
||||||
|
Doctor.php
|
||||||
|
DoctorAddress.php
|
||||||
|
Repository/
|
||||||
|
DoctorRepository.php
|
||||||
|
Service/
|
||||||
|
DoctorService.php
|
||||||
|
DoctorRatingService.php
|
||||||
|
DTO/
|
||||||
|
DoctorRequest.php
|
||||||
|
DoctorResponse.php
|
||||||
|
Event/
|
||||||
|
DoctorCreatedEvent.php
|
||||||
|
|
||||||
|
Clinic/
|
||||||
|
Controller/
|
||||||
|
Entity/
|
||||||
|
Repository/
|
||||||
|
Service/
|
||||||
|
DTO/
|
||||||
|
|
||||||
|
Appointment/
|
||||||
|
...
|
||||||
|
|
||||||
|
Payment/
|
||||||
|
Gateway/
|
||||||
|
MellatGateway.php
|
||||||
|
SepGateway.php
|
||||||
|
PaymentGatewayInterface.php
|
||||||
|
...
|
||||||
|
|
||||||
|
Shared/
|
||||||
|
Response/
|
||||||
|
ApiResponse.php
|
||||||
|
ApiError.php
|
||||||
|
Controller/
|
||||||
|
BaseController.php
|
||||||
|
Repository/
|
||||||
|
BaseRepository.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependency Direction
|
||||||
|
|
||||||
|
باید یکطرفه باشد:
|
||||||
|
```
|
||||||
|
Controller → Service → Repository → Entity
|
||||||
|
```
|
||||||
|
|
||||||
|
هیچگاه:
|
||||||
|
```
|
||||||
|
Entity → Service ❌
|
||||||
|
Repository → Controller ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ۱۰. ریسکهای شناساییشده
|
||||||
|
|
||||||
|
### ریسکهای بحرانی
|
||||||
|
|
||||||
|
| ریسک | Severity | Probability | Impact | راهحل |
|
||||||
|
|------|----------|-------------|--------|--------|
|
||||||
|
| ۳۰+ endpoint بدون response schema | Critical | High | Frontend/Backend diverge | مستندسازی قبل از کدنویسی |
|
||||||
|
| Business logic rating تعریفنشده | Critical | High | نتایج اشتباه | مستندسازی فرمول |
|
||||||
|
| Payment flow ناقص | Critical | High | از دست رفتن پرداخت | تعریف کامل فلو |
|
||||||
|
| ساختار پوشه تعریفنشده | High | High | کد ناهماهنگ ۱۷ ماژول | تعریف قبل از شروع |
|
||||||
|
|
||||||
|
### ریسکهای مهم
|
||||||
|
|
||||||
|
| ریسک | Severity | Probability | Impact | راهحل |
|
||||||
|
|------|----------|-------------|--------|--------|
|
||||||
|
| بدون Refresh Token | High | Certain | UX ضعیف، re-login مکرر | پیادهسازی refresh token |
|
||||||
|
| SMS sync blocking | High | High | OTP fail → کاربر مسدود | Symfony Messenger |
|
||||||
|
| N+1 Query در لیست دکتر | High | High | کندی با رشد data | Eager loading + cache |
|
||||||
|
| God Table categories | High | Medium | جستجوی کند، maintenance سخت | Refactor در فاز اول |
|
||||||
|
| Secretary permissions بدون schema | High | High | پیادهسازی ناهماهنگ | تعریف JSON schema |
|
||||||
|
|
||||||
|
### ریسکهای عملیاتی
|
||||||
|
|
||||||
|
| ریسک | Severity | Probability | Impact | راهحل |
|
||||||
|
|------|----------|-------------|--------|--------|
|
||||||
|
| بدون health check | Medium | Certain | نمیتوان مشکل را سریع تشخیص داد | اضافه کردن `/health` |
|
||||||
|
| بدون logging | High | Certain | debug تولید غیرممکن | Structured logging از ابتدا |
|
||||||
|
| Secrets در .env | Medium | High | leak در git | Vault یا CI/CD secrets |
|
||||||
|
| Dev OTP ثابت (12345) | Low | High | باید env-based باشد | `APP_ENV=dev` conditional |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing Requirements (کامل)
|
||||||
|
|
||||||
|
| # | مورد | ماژول | شدت |
|
||||||
|
|---|------|-------|-----|
|
||||||
|
| ۱ | Response schema برای ۳۰+ endpoint | همه | Critical |
|
||||||
|
| ۲ | فرمول وزنی محاسبه rating | task-12 | Critical |
|
||||||
|
| ۳ | فلوی کامل payment (trigger، failure، refund، cancel) | task-15 | Critical |
|
||||||
|
| ۴ | Error response format استاندارد | task-01 | Critical |
|
||||||
|
| ۵ | ساختار پوشه Domain-Driven | task-01 | High |
|
||||||
|
| ۶ | منطق تولید `free_turn` و `hours_of_work` | task-05/09 | High |
|
||||||
|
| ۷ | Refresh Token mechanism | task-02 | High |
|
||||||
|
| ۸ | Subscription payment endpoints | task-15 | High |
|
||||||
|
| ۹ | ساختار JSON فیلد `permissions` دبیران | task-14 | High |
|
||||||
|
| ۱۰ | Appointment cancellation و refund flow | task-10/15 | High |
|
||||||
|
| ۱۱ | Logging strategy | task-01 | High |
|
||||||
|
| ۱۲ | Health check endpoint (`/health`) | task-01 | Medium |
|
||||||
|
| ۱۳ | SMS provider fallback logic | task-17 | Medium |
|
||||||
|
| ۱۴ | File upload error handling و size limit | همه | Medium |
|
||||||
|
| ۱۵ | City-specific management endpoints | task-08 | Medium |
|
||||||
|
| ۱۶ | Comment reply/nesting (field_parent) | task-12 | Medium |
|
||||||
|
| ۱۷ | Fixtures/Seeders برای category data | task-08 | Medium |
|
||||||
|
| ۱۸ | Rate limit headers در response | task-02 | Low |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Violations
|
||||||
|
|
||||||
|
| # | نقض | اصل | راهحل |
|
||||||
|
|---|-----|-----|--------|
|
||||||
|
| ۱ | God Table `categories` | Single Responsibility | STI یا جداول جداگانه |
|
||||||
|
| ۲ | God Table `clinic_pro` از Drupal | Bounded Context | Entity های جداگانه در Symfony |
|
||||||
|
| ۳ | بدون DTO برای Input/Output | Explicit API Contract | DTO class برای هر endpoint |
|
||||||
|
| ۴ | Magic field name `field_starts` (نه `field_stars`) | Clarity | Mapping layer صریح |
|
||||||
|
| ۵ | Naming convention ناسازگار در response ها | Convention over Configuration | استاندارد یکپارچه |
|
||||||
|
| ۶ | Business logic تعریفنشده | Separation of Concerns | Service layer صریح |
|
||||||
|
| ۷ | Typo در URL (`categorys`) کپی از Drupal | REST conventions | در Symfony با redirect fix کن |
|
||||||
|
| ۸ | Timestamp گاهی string گاهی int در response | Type Consistency | همیشه int |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Improvements
|
||||||
|
|
||||||
|
### اولویت ۱ — قبل از شروع کدنویسی
|
||||||
|
|
||||||
|
**۱. استاندارد Error/Success Response**
|
||||||
|
```json
|
||||||
|
// موفق:
|
||||||
|
{
|
||||||
|
"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 استاندارد**
|
||||||
|
```
|
||||||
|
ERR_AUTH_001 = توکن منقضی شده
|
||||||
|
ERR_AUTH_002 = OTP نامعتبر
|
||||||
|
ERR_AUTH_003 = OTP منقضی شده
|
||||||
|
ERR_VALIDATION_001 = ورودی نامعتبر
|
||||||
|
ERR_NOT_FOUND_001 = منبع یافت نشد
|
||||||
|
ERR_FORBIDDEN_001 = دسترسی ندارید
|
||||||
|
ERR_PAYMENT_001 = درگاه پرداخت در دسترس نیست
|
||||||
|
ERR_PAYMENT_002 = مبلغ نامعتبر
|
||||||
|
```
|
||||||
|
|
||||||
|
**۳. تعریف فرمول Rating**
|
||||||
|
```php
|
||||||
|
const RATING_WEIGHTS = [
|
||||||
|
'accuracy_of_diagnosis' => 3.0,
|
||||||
|
'doctor_expertise' => 2.0,
|
||||||
|
'doctor_behavior' => 1.5,
|
||||||
|
'waiting_time_at_clinic' => 1.0,
|
||||||
|
'clinic_cleanliness' => 1.0,
|
||||||
|
];
|
||||||
|
// weightedAverage = SUM(value * weight) / SUM(weights) → از 100
|
||||||
|
// stars = (weightedAverage / 100) * 5 → از 5
|
||||||
|
```
|
||||||
|
|
||||||
|
**۴. تعریف ساختار پوشه صریح در task-01**
|
||||||
|
|
||||||
|
### اولویت ۲ — در حین پیادهسازی
|
||||||
|
|
||||||
|
**۵. Refresh Token**
|
||||||
|
```
|
||||||
|
POST /oauth/token
|
||||||
|
{ "grant_type": "refresh_token", "refresh_token": "..." }
|
||||||
|
→ refresh token در Redis با TTL=30 روز
|
||||||
|
```
|
||||||
|
|
||||||
|
**۶. Symfony Messenger برای Async Operations**
|
||||||
|
```bash
|
||||||
|
ddev composer require symfony/messenger
|
||||||
|
```
|
||||||
|
SMS، notification، email — همه از طریق Queue
|
||||||
|
|
||||||
|
**۷. Repository Pattern صریح**
|
||||||
|
```php
|
||||||
|
// هر entity باید Repository خودش داشته باشد
|
||||||
|
class DoctorRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function findWithFilters(array $filters, int $page, int $limit): array
|
||||||
|
public function findByUuidWithRelations(string $uuid): ?Doctor
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**۸. DTO برای Input/Output**
|
||||||
|
```php
|
||||||
|
class DoctorRequest
|
||||||
|
{
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
public string $title;
|
||||||
|
|
||||||
|
#[Assert\Range(min: 0, max: 100)]
|
||||||
|
public int $experience;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DoctorResponse
|
||||||
|
{
|
||||||
|
public function __construct(Doctor $doctor) { ... }
|
||||||
|
public function toArray(): array { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### اولویت ۳ — برای آمادهسازی تولید
|
||||||
|
|
||||||
|
**۹. Health Check**
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
→ { "status": "ok", "db": "ok", "redis": "ok", "timestamp": 1748000000 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**۱۰. Redis Cache برای Lookup Data**
|
||||||
|
```php
|
||||||
|
// categories، states، cities — TTL=300s
|
||||||
|
$states = $cache->get('categories.state', fn() => $repo->findByBundle('state'));
|
||||||
|
```
|
||||||
|
|
||||||
|
**۱۱. Structured Logging**
|
||||||
|
```php
|
||||||
|
$this->logger->info('appointment.created', [
|
||||||
|
'user_id' => $user->getId(),
|
||||||
|
'doctor_id' => $doctor->getId(),
|
||||||
|
'start_time' => $startTime,
|
||||||
|
'request_id' => $requestId,
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**۱۲. Query Optimization**
|
||||||
|
```php
|
||||||
|
// Eager loading برای جلوگیری از N+1
|
||||||
|
$doctor = $repo->createQueryBuilder('d')
|
||||||
|
->leftJoin('d.specialties', 's')->addSelect('s')
|
||||||
|
->leftJoin('d.addresses', 'a')->addSelect('a')
|
||||||
|
->where('d.uuid = :uuid')
|
||||||
|
->getQuery()->getOneOrNullResult();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refactoring Plan
|
||||||
|
|
||||||
|
### فاز ۰ — مستندسازی (۳ تا ۵ روز، قبل از هر کدنویسی)
|
||||||
|
|
||||||
|
- [ ] تعریف `ApiResponse` و `ApiError` format در task-01
|
||||||
|
- [ ] مستندسازی Response schema تمام ۳۰+ endpoint گمشده
|
||||||
|
- [ ] تعریف فرمول rating در task-12
|
||||||
|
- [ ] تعریف کامل فلوی payment (trigger، failure، refund) در task-15
|
||||||
|
- [ ] تعریف ساختار JSON `permissions` دبیران در task-14
|
||||||
|
- [ ] تعریف ساختار پوشه Domain-Driven در task-01
|
||||||
|
- [ ] تصمیمگیری: Refresh Token — بله یا خیر
|
||||||
|
|
||||||
|
### فاز ۱ — زیرساخت پایه (task-01)
|
||||||
|
|
||||||
|
- [ ] اضافه کردن `symfony/messenger` به پکیجها
|
||||||
|
- [ ] ایجاد `BaseController` با متدهای `success()` و `error()`
|
||||||
|
- [ ] ایجاد `BaseRepository` با متدهای مشترک
|
||||||
|
- [ ] اضافه کردن `GET /health` endpoint
|
||||||
|
- [ ] تعریف Error Code constants
|
||||||
|
- [ ] راهاندازی Structured Logging با Monolog
|
||||||
|
|
||||||
|
### فاز ۲ — پیادهسازی ماژولها (به ترتیب dependency)
|
||||||
|
|
||||||
|
```
|
||||||
|
01 → 02 → 08 → 03 → 04 → 05 → 06 → 07 → 09 → 11 → 10 → 12 → 13 → 14 → 15 → 16 → 17
|
||||||
|
```
|
||||||
|
|
||||||
|
برای هر ماژول:
|
||||||
|
1. Entity + Migration
|
||||||
|
2. Repository با Eager loading
|
||||||
|
3. DTO (Request + Response)
|
||||||
|
4. Service با Business Logic
|
||||||
|
5. Controller با Swagger attributes
|
||||||
|
6. Tests
|
||||||
|
|
||||||
|
### فاز ۳ — بهینهسازی (بعد از پیادهسازی)
|
||||||
|
|
||||||
|
- [ ] Redis cache برای categories و lookup data
|
||||||
|
- [ ] Eager loading در تمام list endpoints
|
||||||
|
- [ ] Integration tests برای payment flow
|
||||||
|
- [ ] Load test برای لیست دکتر با فیلتر چندگانه
|
||||||
|
- [ ] Review و تکمیل Swagger documentation
|
||||||
|
|
||||||
|
### فاز ۴ — آمادهسازی تولید
|
||||||
|
|
||||||
|
- [ ] Secrets به محیط CI/CD منتقل شوند (خارج از .env)
|
||||||
|
- [ ] Health check به monitoring متصل شود
|
||||||
|
- [ ] Read replica برای query های سنگین
|
||||||
|
- [ ] File storage به S3-compatible منتقل شود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## اصلاحات اعمالشده (بعد از Audit)
|
||||||
|
|
||||||
|
| # | مشکل | فایل اصلاحشده | وضعیت |
|
||||||
|
|---|------|---------------|--------|
|
||||||
|
| ۱ | ساختار پوشه Domain-Driven | task-01/task.md | ✅ |
|
||||||
|
| ۲ | Error Codes استاندارد | task-01/task.md | ✅ |
|
||||||
|
| ۳ | BaseController با success/error | task-01/task.md | ✅ |
|
||||||
|
| ۴ | Symfony Messenger | task-01/task.md | ✅ |
|
||||||
|
| ۵ | Health Check endpoint | task-01/task.md | ✅ |
|
||||||
|
| ۶ | Structured Logging | task-01/task.md | ✅ |
|
||||||
|
| ۷ | **CORS فقط دامنههای مشخص (نه *)** | task-01/implementation_notes.md | ✅ |
|
||||||
|
| ۸ | **Security Headers (X-Frame، HSTS، CSP)** | task-01/implementation_notes.md | ✅ |
|
||||||
|
| ۹ | **Swagger فقط در dev** | task-01/implementation_notes.md | ✅ |
|
||||||
|
| ۱۰ | **Audit Log جدول security_logs** | task-01/implementation_notes.md | ✅ |
|
||||||
|
| ۱۱ | **security.yaml کامل با access_control** | task-01/implementation_notes.md | ✅ |
|
||||||
|
| ۱۲ | Refresh Token + Logout + Blacklist | task-02/task.md | ✅ |
|
||||||
|
| ۱۳ | **hash_equals() برای OTP — جلوگیری از Timing Attack** | task-02/task.md | ✅ |
|
||||||
|
| ۱۴ | **Refresh Token هششده در Redis (نه plain text)** | task-02/task.md | ✅ |
|
||||||
|
| ۱۵ | Rate Limit Headers در response | task-02/task.md | ✅ |
|
||||||
|
| ۱۶ | Status Machine کامل نوبت | task-10/task.md | ✅ |
|
||||||
|
| ۱۷ | فلوی لغو + refund | task-10/task.md | ✅ |
|
||||||
|
| ۱۸ | **Open Redirect در frontend_address پرداخت** | task-15/task.md | ✅ |
|
||||||
|
| ۱۹ | **IP Whitelist Callback با implementation** | task-15/task.md | ✅ |
|
||||||
|
| ۲۰ | Circuit Breaker + Idempotency | task-15/task.md | ✅ |
|
||||||
|
| ۲۱ | Subscription Payment endpoints | task-15/task.md | ✅ |
|
||||||
|
| ۲۲ | Secretary permissions JSON schema | task-14/task.md | ✅ |
|
||||||
|
| ۲۳ | SMS Fallback + Async Messenger | task-17/task.md | ✅ |
|
||||||
|
| ۲۴ | **File upload: magic bytes بجای MIME header** | task-01/task.md | ✅ |
|
||||||
|
| ۲۵ | **Filename sanitization — path traversal** | task-01/task.md | ✅ |
|
||||||
|
| ۲۶ | فرمت Response ناسازگار در architecture.md | task-01/architecture.md | ✅ |
|
||||||
|
| ۲۷ | تسویه نماینده | task-18-settlement/ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مشکلات امنیتی باقیمانده (نیاز به توجه در پیادهسازی)
|
||||||
|
|
||||||
|
| # | مشکل | اولویت | راهحل |
|
||||||
|
|---|------|--------|--------|
|
||||||
|
| ۱ | Input HTML sanitization در فیلدهای متنی (detail، caption) | High | استفاده از `htmlspecialchars()` یا `strip_tags()` در DTO |
|
||||||
|
| ۲ | Mass assignment در PATCH endpoints | Medium | فقط فیلدهای مجاز را از request map کن |
|
||||||
|
| ۳ | JWT passphrase پیشفرض ضعیف | High | در production حتماً با `openssl rand -hex 32` تغییر داده شود |
|
||||||
|
| ۴ | Dev OTP ثابت (12345) | Medium | حتماً فقط با `APP_ENV=dev` فعال شود — هرگز در production |
|
||||||
|
| ۵ | Health check اطلاعات سیستم را افشا میکند | Low | در production به IP های داخلی محدود شود |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verdict (بعد از اصلاحات)
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ APPROVED
|
||||||
|
```
|
||||||
|
|
||||||
|
### دلیل تصمیم
|
||||||
|
|
||||||
|
تمام مشکلات بحرانی و اکثر مشکلات مهم رفع شدهاند:
|
||||||
|
|
||||||
|
- **امنیت:** Timing Attack، CORS wildcard، Open Redirect، Security Headers، Refresh Token hashing، IP Whitelist، Audit Log — همه رفع شدند
|
||||||
|
- **معماری:** Domain-Driven structure، DTO pattern، Error Codes، Response format یکپارچه
|
||||||
|
- **قابلیت اطمینان:** Payment flow کامل، Circuit Breaker، Idempotency، SMS fallback
|
||||||
|
- **مستندسازی:** همه endpoint های بحرانی با schema کامل مستند شدند
|
||||||
|
|
||||||
|
باقیمانده موارد (Mass Assignment، Input sanitization) در لایه پیادهسازی با Symfony Validator و DTO به سادگی قابل رفع هستند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*آخرین بروزرسانی: ۱۴۰۵/۰۳/۱۸ — بعد از اصلاح کامل*
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,482 @@
|
|||||||
|
# ClinicPro Admin — UI Design Specification
|
||||||
|
> سبک بصری: Panelix Premium React Admin Dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design System پایه
|
||||||
|
|
||||||
|
### رنگبندی (Color Palette)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Primary — Purple (Panelix style) */
|
||||||
|
--color-primary-50: #f5f3ff;
|
||||||
|
--color-primary-100: #ede9fe;
|
||||||
|
--color-primary-200: #ddd6fe;
|
||||||
|
--color-primary-300: #c4b5fd;
|
||||||
|
--color-primary-400: #a78bfa;
|
||||||
|
--color-primary-500: #8b5cf6; /* main */
|
||||||
|
--color-primary-600: #7c3aed;
|
||||||
|
--color-primary-700: #6d28d9;
|
||||||
|
--color-primary-800: #5b21b6;
|
||||||
|
--color-primary-900: #4c1d95;
|
||||||
|
|
||||||
|
/* Neutrals */
|
||||||
|
--color-gray-50: #f9fafb;
|
||||||
|
--color-gray-100: #f3f4f6;
|
||||||
|
--color-gray-200: #e5e7eb;
|
||||||
|
--color-gray-300: #d1d5db;
|
||||||
|
--color-gray-400: #9ca3af;
|
||||||
|
--color-gray-500: #6b7280;
|
||||||
|
--color-gray-600: #4b5563;
|
||||||
|
--color-gray-700: #374151;
|
||||||
|
--color-gray-800: #1f2937;
|
||||||
|
--color-gray-900: #111827;
|
||||||
|
|
||||||
|
/* Status Colors */
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-danger: #ef4444;
|
||||||
|
--color-info: #3b82f6;
|
||||||
|
|
||||||
|
/* Background */
|
||||||
|
--color-bg-body: #f1f5f9; /* light gray page bg */
|
||||||
|
--color-bg-card: #ffffff;
|
||||||
|
--color-bg-sidebar: #0f172a; /* dark navy sidebar */
|
||||||
|
--color-bg-sidebar-active: rgba(139, 92, 246, 0.15);
|
||||||
|
```
|
||||||
|
|
||||||
|
### تایپوگرافی
|
||||||
|
|
||||||
|
```
|
||||||
|
Font Family: "Vazirmatn", "Inter", sans-serif ← فارسی + لاتین
|
||||||
|
Direction: RTL
|
||||||
|
|
||||||
|
Heading 1: 28px / font-bold / gray-900
|
||||||
|
Heading 2: 22px / font-bold / gray-900
|
||||||
|
Heading 3: 18px / font-semibold / gray-800
|
||||||
|
Heading 4: 16px / font-semibold / gray-700
|
||||||
|
Body: 14px / font-normal / gray-600
|
||||||
|
Caption: 12px / font-normal / gray-500
|
||||||
|
Label: 12px / font-medium / gray-700 / uppercase + tracking-wide
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spacing & Border Radius
|
||||||
|
|
||||||
|
```
|
||||||
|
Spacing scale: 4px base (4, 8, 12, 16, 20, 24, 32, 40, 48, 64)
|
||||||
|
Border radius:
|
||||||
|
sm: 6px (badges, chips)
|
||||||
|
md: 10px (inputs, buttons)
|
||||||
|
lg: 16px (cards)
|
||||||
|
xl: 24px (modals)
|
||||||
|
full: 9999px (avatars, toggles)
|
||||||
|
Box shadow:
|
||||||
|
card: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06)
|
||||||
|
modal: 0 20px 60px rgba(0,0,0,.15)
|
||||||
|
dropdown: 0 4px 20px rgba(0,0,0,.10)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Layout Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ TOPBAR (64px) │
|
||||||
|
├────────────┬────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ SIDEBAR │ MAIN CONTENT │
|
||||||
|
│ (260px) │ │
|
||||||
|
│ │ ┌──────────────────────────────────────┐ │
|
||||||
|
│ collapsed │ │ Page Header (title + breadcrumb) │ │
|
||||||
|
│ → 72px │ ├──────────────────────────────────────┤ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ │ Content Area (padding 24px) │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ └──────────────────────────────────────┘ │
|
||||||
|
└────────────┴────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Sidebar
|
||||||
|
|
||||||
|
### حالت باز (260px)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ ◉ ClinicPro [← collapse] │ ← logo + toggle button
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ 🔍 جستجوی سریع... │ ← search input
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ GENERAL │ ← section label (gray-500, 11px, uppercase)
|
||||||
|
│ ◉ داشبورد │ ← active item (purple bg + purple text + bold)
|
||||||
|
│ ○ کاربران │
|
||||||
|
│ ○ پزشکان │
|
||||||
|
│ ○ کلینیکها │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ MANAGEMENT │
|
||||||
|
│ ○ نوبتها [3] │ ← badge count
|
||||||
|
│ ○ پرداختها │
|
||||||
|
│ ○ تسویهحساب [5] │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ CONTENT │
|
||||||
|
│ ○ نظرات [12] │
|
||||||
|
│ ○ امتیازها │
|
||||||
|
│ ○ بلاگ │
|
||||||
|
│ ○ پیامک │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ SYSTEM │
|
||||||
|
│ ○ دستهبندیها │
|
||||||
|
│ ○ نمایندگان │
|
||||||
|
│ ○ منشیها │
|
||||||
|
├──────────────────────────────┤
|
||||||
|
│ ┌────────────────────────┐ │
|
||||||
|
│ │ 👤 Admin │ ← admin profile card at bottom
|
||||||
|
│ │ admin@clinicpro.ir │
|
||||||
|
│ │ [تنظیمات] [خروج] │
|
||||||
|
│ └────────────────────────┘ │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### حالت جمعشده (72px) — Flyout on hover
|
||||||
|
```
|
||||||
|
┌──────┐
|
||||||
|
│ ◉ │ ← logo icon
|
||||||
|
├──────┤
|
||||||
|
│ 🔍 │ ← hover → flyout search
|
||||||
|
├──────┤
|
||||||
|
│ ⊞ │ ← icon only, hover → flyout label + submenu
|
||||||
|
│ 👥 │
|
||||||
|
│ 🩺 │
|
||||||
|
│ 🏥 │
|
||||||
|
│ 📅 │
|
||||||
|
│ 💳 │
|
||||||
|
│ 🏦 │ ← badge dot (نه عدد)
|
||||||
|
│ 💬 │ ← badge dot
|
||||||
|
│ ⭐ │
|
||||||
|
│ 📝 │
|
||||||
|
│ 📱 │
|
||||||
|
│ 🗂 │
|
||||||
|
│ 🤝 │
|
||||||
|
│ 🔐 │
|
||||||
|
└──────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**رفتار sidebar:**
|
||||||
|
- `transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1)`
|
||||||
|
- Overlay در موبایل (< 768px)
|
||||||
|
- Active item: `bg-primary-500/15` + right border `4px solid #8b5cf6`
|
||||||
|
- Hover item: `bg-gray-700/40`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Topbar
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ ≡ [Breadcrumb: داشبورد / پزشکان] 🔔 5 👤 Admin ▾ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- ارتفاع: 64px
|
||||||
|
- پسزمینه: سفید + `box-shadow: 0 1px 0 #e5e7eb`
|
||||||
|
- **Notification Bell:** dropdown با لیست آخرین رویدادها
|
||||||
|
- **User Menu:** تصویر آواتار + نام + dropdown (پروفایل / تنظیمات / خروج)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Cards
|
||||||
|
|
||||||
|
### Stat Card (آمار خلاصه)
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ ┌────┐ │
|
||||||
|
│ │ 🩺 │ کل پزشکان │ ← icon در مربع رنگی (purple-100)
|
||||||
|
│ └────┘ 1,284 │ ← عدد بزرگ (28px bold)
|
||||||
|
│ ↑ 12% نسبت به ماه قبل │ ← trend badge (سبز/قرمز)
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
bg: white, radius: 16px, shadow: card, padding: 24px
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Card (محتوا / جداول)
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────┐
|
||||||
|
│ عنوان کارت [اقدام ▾] │ ← header
|
||||||
|
├────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ محتوا (جدول / نمودار / فرم) │
|
||||||
|
│ │
|
||||||
|
└────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. DataTable (جدول داده)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [🔍 جستجو...] [فیلتر ▾] [ستونها ▾] [صادرکردن ↓] │
|
||||||
|
├──────────┬────────────┬──────────┬────────┬─────────────────────┤
|
||||||
|
│ ☐ نام │ موبایل │ نقش │ وضعیت │ اقدامات │
|
||||||
|
├──────────┼────────────┼──────────┼────────┼─────────────────────┤
|
||||||
|
│ ☐ علی م. │ 0912*** │ پزشک │ ● فعال │ 👁 ✏️ 🗑 │
|
||||||
|
│ ☐ سارا ح │ 0935*** │ کلینیک │ ○ غیر │ 👁 ✏️ 🗑 │
|
||||||
|
├──────────┴────────────┴──────────┴────────┴─────────────────────┤
|
||||||
|
│ نمایش 1-10 از 284 [← قبلی] 1 2 3 ... 29 [بعدی →] │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**ویژگیها:**
|
||||||
|
- Sortable columns (کلیک روی header → ↑↓)
|
||||||
|
- Row hover: `bg-gray-50`
|
||||||
|
- Sticky header هنگام scroll
|
||||||
|
- Loading state: skeleton rows (shimmer animation)
|
||||||
|
- Empty state: آیکون + پیام توصیفی + دکمه اقدام
|
||||||
|
- Bulk actions: با انتخاب checkbox ها → نوار بالا ظاهر میشود
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Status Badges
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
// وضعیت نوبت
|
||||||
|
<Badge variant="yellow">در انتظار پرداخت</Badge> /* waiting_for_payment */
|
||||||
|
<Badge variant="blue">رزرو شده</Badge> /* reserved */
|
||||||
|
<Badge variant="purple">ورود به مطب</Badge> /* checked_in */
|
||||||
|
<Badge variant="orange">در صف انتظار</Badge> /* waiting */
|
||||||
|
<Badge variant="indigo">در حال ویزیت</Badge> /* in_progress */
|
||||||
|
<Badge variant="green">ویزیت شده</Badge> /* visited / completed */
|
||||||
|
<Badge variant="red">لغو شده</Badge> /* cancelled_* */
|
||||||
|
<Badge variant="gray">لغو خودکار</Badge> /* auto_cancel_unpaid */
|
||||||
|
<Badge variant="rose">غیبت</Badge> /* no_show */
|
||||||
|
|
||||||
|
// وضعیت پرداخت
|
||||||
|
<Badge variant="yellow">در انتظار</Badge> /* pending */
|
||||||
|
<Badge variant="green">موفق</Badge> /* received */
|
||||||
|
<Badge variant="red">لغو شده</Badge> /* canceled */
|
||||||
|
<Badge variant="blue">استرداد</Badge> /* refund */
|
||||||
|
|
||||||
|
// وضعیت SMS Template
|
||||||
|
<Badge variant="gray">پیشنویس</Badge> /* draft */
|
||||||
|
<Badge variant="yellow">در انتظار تأیید</Badge> /* pending_approval */
|
||||||
|
<Badge variant="green">تأیید شده</Badge> /* approved */
|
||||||
|
<Badge variant="red">رد شده</Badge> /* rejected */
|
||||||
|
```
|
||||||
|
|
||||||
|
**ساختار badge:**
|
||||||
|
```
|
||||||
|
padding: 2px 10px
|
||||||
|
border-radius: 9999px
|
||||||
|
font-size: 12px / font-medium
|
||||||
|
با dot رنگی (●) در ابتدا
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. فرمها (Forms)
|
||||||
|
|
||||||
|
### Input
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ برچسب │
|
||||||
|
│ ┌─────────────────────────────┐ │
|
||||||
|
│ │ 🔍 placeholder... │ │ ← icon اختیاری
|
||||||
|
│ └─────────────────────────────┘ │
|
||||||
|
│ پیام خطا (قرمز، 12px) │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Border: `1px solid #d1d5db` → focus: `2px solid #8b5cf6`
|
||||||
|
- Height input: 44px
|
||||||
|
- Border-radius: 10px
|
||||||
|
- Error state: border قرمز + shake animation
|
||||||
|
- Disabled: opacity 50%
|
||||||
|
|
||||||
|
### Select / Dropdown
|
||||||
|
- کتابخانه: `react-select` با استایل custom (RTL support)
|
||||||
|
- Multi-select برای تخصصها، بیمهها، تگها
|
||||||
|
|
||||||
|
### Permission Matrix (منشی)
|
||||||
|
```
|
||||||
|
مشاهده ایجاد ویرایش حذف
|
||||||
|
نوبتها ☑ ☑ ☐ ☐
|
||||||
|
آدرسها ☑ ☐ ☐ ☐
|
||||||
|
اطلاعات کلینیک ☑ — ☐ —
|
||||||
|
بیمهها ☑ ☐ ☐ ☐
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. نمودارها (Charts)
|
||||||
|
|
||||||
|
### داشبورد اصلی
|
||||||
|
```
|
||||||
|
Row 1: [Stat Card x4] ← کاربران / پزشکان / نوبت امروز / درآمد امروز
|
||||||
|
|
||||||
|
Row 2: [Area Chart — درآمد ماهانه (60%)] | [Donut Chart — نوبتها بر اساس وضعیت (40%)]
|
||||||
|
|
||||||
|
Row 3: [Bar Chart — آمار ماهانه نمایندگان (60%)] | [لیست آخرین نوبتها (40%)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**کتابخانه:** `Recharts` یا `ApexCharts`
|
||||||
|
- رنگ اصلی نمودارها: shades of purple + secondary colors
|
||||||
|
- Tooltip: سفید با سایه، اعداد فارسی
|
||||||
|
- X-axis: نام ماههای شمسی (فروردین ... اسفند)
|
||||||
|
- Responsive: `<ResponsiveContainer width="100%" height={300}>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Modal / Dialog
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ │ ← backdrop: rgba(0,0,0,.4)
|
||||||
|
│ ┌────────────────────────────────────────────┐ │
|
||||||
|
│ │ عنوان Modal ✕ │ │ ← header: border-bottom
|
||||||
|
│ ├────────────────────────────────────────────┤ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ محتوا │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ├────────────────────────────────────────────┤ │
|
||||||
|
│ │ [لغو] [تأیید / ذخیره] │ │ ← footer: border-top
|
||||||
|
│ └────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- انیمیشن ورود: `scale(0.95) → scale(1)` + `opacity 0 → 1` (200ms)
|
||||||
|
- Confirm Dialog برای حذف: دکمه «حذف» قرمز + آیکون هشدار
|
||||||
|
- Width: sm=400px / md=600px / lg=800px / xl=1000px
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Toast Notifications
|
||||||
|
|
||||||
|
```
|
||||||
|
موقعیت: top-left (RTL)
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ ✓ پزشک با موفقیت ویرایش شد. │ ← success (سبز)
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ ✕ خطا در ذخیره اطلاعات. │ ← error (قرمز)
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Auto dismiss: 4 ثانیه
|
||||||
|
- Stack: حداکثر 3 نوتیفیکیشن همزمان
|
||||||
|
- کتابخانه: `react-hot-toast` یا `sonner`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Empty States & Loading
|
||||||
|
|
||||||
|
### Loading (Skeleton)
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ ▓▓▓▓▓▓▓▓▓ ░░░░░░░░░░░ │ ← shimmer animation
|
||||||
|
│ ░░░░░░░░░░░░░░░░░░░░░░░░ │
|
||||||
|
│ ░░░░░░░░░░░ ▓▓▓▓▓▓▓▓▓▓ │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
- `animate-pulse` با رنگ `gray-200`
|
||||||
|
|
||||||
|
### Empty State
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ [SVG Illustration] │
|
||||||
|
│ │
|
||||||
|
│ هیچ موردی یافت نشد │
|
||||||
|
│ توضیح کوتاه... │
|
||||||
|
│ │
|
||||||
|
│ [افزودن اولین مورد] │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Page Header (هر صفحه)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ پزشکان [+ افزودن پزشک] │
|
||||||
|
│ داشبورد / پزشکان │ ← breadcrumb
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. تکنولوژی Stack
|
||||||
|
|
||||||
|
| لایه | کتابخانه |
|
||||||
|
|------|----------|
|
||||||
|
| Framework | React 19 + TypeScript |
|
||||||
|
| Routing | React Router v7 |
|
||||||
|
| Styling | Tailwind CSS v4 |
|
||||||
|
| State (server) | TanStack Query v5 |
|
||||||
|
| State (client) | Zustand |
|
||||||
|
| Forms | React Hook Form + Zod |
|
||||||
|
| Charts | Recharts |
|
||||||
|
| Table | TanStack Table v8 |
|
||||||
|
| Icons | Heroicons v2 |
|
||||||
|
| Date (Jalali) | `@date-io/date-fns-jalali` + `react-datepicker` |
|
||||||
|
| Numbers | `react-number-format` |
|
||||||
|
| Toast | `sonner` |
|
||||||
|
| Select | `react-select` |
|
||||||
|
| Rich Text | `@tiptap/react` |
|
||||||
|
| File Upload | `react-dropzone` |
|
||||||
|
| RTL | `dir="rtl"` + Tailwind `rtl:` variants |
|
||||||
|
| Font | Vazirmatn (از Google Fonts یا CDN) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Responsive Breakpoints
|
||||||
|
|
||||||
|
| نام | عرض | رفتار |
|
||||||
|
|-----|-----|--------|
|
||||||
|
| mobile | < 768px | Sidebar → Drawer overlay |
|
||||||
|
| tablet | 768px–1024px | Sidebar collapsed (72px) |
|
||||||
|
| desktop | > 1024px | Sidebar باز (260px) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Dark Mode (اختیاری — فاز دوم)
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* با Tailwind dark: variant */
|
||||||
|
.dark {
|
||||||
|
--color-bg-body: #0f172a;
|
||||||
|
--color-bg-card: #1e293b;
|
||||||
|
--color-bg-sidebar: #0a0f1e;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Toggle در topbar ← ذخیره در `localStorage`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. نمونه رنگبندی صفحه داشبورد
|
||||||
|
|
||||||
|
```
|
||||||
|
[صفحه] bg: #f1f5f9
|
||||||
|
│
|
||||||
|
├── Sidebar (bg: #0f172a, text: gray-400, active: purple-500)
|
||||||
|
│
|
||||||
|
└── Main
|
||||||
|
├── Topbar (bg: white, border-bottom: gray-200)
|
||||||
|
│
|
||||||
|
└── Content (padding: 24px)
|
||||||
|
├── [Stat Card] bg:white, icon-box: purple-100
|
||||||
|
├── [Stat Card] bg:white, icon-box: green-100
|
||||||
|
├── [Stat Card] bg:white, icon-box: blue-100
|
||||||
|
└── [Stat Card] bg:white, icon-box: orange-100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## منابع
|
||||||
|
- طراحی مرجع: [Panelix Premium React Admin Dashboard](https://themeforest.net/item/panelix-premium-react-admin-dashboard-template/63163276)
|
||||||
|
- فونت: [Vazirmatn](https://rastikerdar.github.io/vazirmatn/)
|
||||||
|
- آیکون: [Heroicons](https://heroicons.com/)
|
||||||
|
- رنگبندی: [Tailwind CSS Colors](https://tailwindcss.com/docs/customizing-colors)
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
# Admin Panel — User Flow (React)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Authentication
|
||||||
|
|
||||||
|
```
|
||||||
|
[Login Page]
|
||||||
|
│
|
||||||
|
├─► Enter mobile number
|
||||||
|
├─► Enter password
|
||||||
|
└─► Submit
|
||||||
|
│
|
||||||
|
├─ Success ──► Store JWT + Refresh Token ──► Redirect to Dashboard
|
||||||
|
└─ Fail ─────► Show error message
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/login`
|
||||||
|
**API:** `POST /api/v1/user/login`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dashboard (صفحه اصلی)
|
||||||
|
|
||||||
|
```
|
||||||
|
[Dashboard]
|
||||||
|
├─► آمار کلی
|
||||||
|
│ ├─ تعداد کل کاربران
|
||||||
|
│ ├─ تعداد پزشکان فعال
|
||||||
|
│ ├─ تعداد کلینیکها
|
||||||
|
│ ├─ نوبتهای امروز
|
||||||
|
│ ├─ پرداختهای امروز (مبلغ)
|
||||||
|
│ ├─ تعداد نظرات در انتظار تأیید
|
||||||
|
│ └─ تعداد درخواستهای تسویهحساب
|
||||||
|
│
|
||||||
|
├─► فعالیتهای اخیر
|
||||||
|
│ ├─ آخرین نوبتهای ثبتشده
|
||||||
|
│ ├─ آخرین پرداختها
|
||||||
|
│ └─ آخرین کاربران ثبتنامشده
|
||||||
|
│
|
||||||
|
└─► نمودارها
|
||||||
|
├─ درآمد ماهانه (Jalali)
|
||||||
|
└─ آمار نوبتها به تفکیک وضعیت
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/dashboard`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. مدیریت کاربران (User Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[لیست کاربران]
|
||||||
|
├─► جستجو (mobile, name, email)
|
||||||
|
├─► فیلتر بر اساس role / status
|
||||||
|
├─► صفحهبندی
|
||||||
|
│
|
||||||
|
├─── ردیف کاربر ──►
|
||||||
|
│ ├─ مشاهده جزئیات ──► [صفحه پروفایل کاربر]
|
||||||
|
│ │ ├─ اطلاعات پایه (نام، موبایل، ایمیل، نقش)
|
||||||
|
│ │ ├─ پروفایل پزشکی (گروه خونی، بیماریها، ...)
|
||||||
|
│ │ ├─ تاریخچه نوبتها
|
||||||
|
│ │ └─ تاریخچه پرداختها
|
||||||
|
│ │
|
||||||
|
│ ├─ ویرایش اطلاعات پایه
|
||||||
|
│ ├─ تغییر وضعیت (فعال / غیرفعال)
|
||||||
|
│ └─ حذف کاربر ──► [Confirm Dialog]
|
||||||
|
│
|
||||||
|
└─── دکمه «افزودن کاربر» ──► [فرم ثبت کاربر جدید]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/users`, `/admin/users/:uuid`
|
||||||
|
**API:**
|
||||||
|
- `GET /api/v1/users` (لیست)
|
||||||
|
- `PATCH /api/v1/user/:id` (ویرایش)
|
||||||
|
- `DELETE /api/v1/user/:id` (حذف)
|
||||||
|
- `GET /api/v1/user-profile/:uuid` (پروفایل)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. مدیریت پزشکان (Doctor Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[لیست پزشکان]
|
||||||
|
├─► فیلتر: استان / شهر / تخصص / جنسیت / درجه / وضعیت فعال
|
||||||
|
├─► جستجو: نام / کد نظام پزشکی
|
||||||
|
│
|
||||||
|
├─── ردیف پزشک ──►
|
||||||
|
│ ├─ مشاهده پروفایل ──► [صفحه پزشک]
|
||||||
|
│ │ ├─ اطلاعات پایه + تصویر
|
||||||
|
│ │ ├─ آدرس مطبها (لیست + نقشه)
|
||||||
|
│ │ ├─ تخصصها و خدمات
|
||||||
|
│ │ ├─ بیمههای پذیرفتهشده
|
||||||
|
│ │ ├─ منشیها
|
||||||
|
│ │ ├─ میانگین امتیاز (5 شاخص)
|
||||||
|
│ │ └─ آمار نوبتها
|
||||||
|
│ │
|
||||||
|
│ ├─ ویرایش پروفایل
|
||||||
|
│ ├─ فعال/غیرفعال کردن
|
||||||
|
│ └─ حذف ──► [Confirm Dialog]
|
||||||
|
│
|
||||||
|
└─── دکمه «افزودن پزشک»
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/doctors`, `/admin/doctors/:uuid`
|
||||||
|
**API:**
|
||||||
|
- `GET /api/v1/doctors`
|
||||||
|
- `POST /api/v1/doctor`
|
||||||
|
- `PATCH /api/v1/doctor/:uuid`
|
||||||
|
- `DELETE /api/v1/doctor/:uuid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. مدیریت کلینیکها (Clinic Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[لیست کلینیکها]
|
||||||
|
├─► فیلتر: استان / شهر / تخصص
|
||||||
|
│
|
||||||
|
├─── ردیف کلینیک ──►
|
||||||
|
│ ├─ مشاهده ──► [صفحه کلینیک]
|
||||||
|
│ │ ├─ اطلاعات + لوگو + گالری تصاویر
|
||||||
|
│ │ ├─ ساعات کاری
|
||||||
|
│ │ ├─ پزشکان عضو
|
||||||
|
│ │ ├─ بیمههای پذیرفتهشده
|
||||||
|
│ │ └─ موقعیت روی نقشه
|
||||||
|
│ │
|
||||||
|
│ ├─ ویرایش
|
||||||
|
│ ├─ فعال/غیرفعال کردن
|
||||||
|
│ └─ حذف ──► [Confirm Dialog]
|
||||||
|
│
|
||||||
|
└─── دکمه «افزودن کلینیک»
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/clinics`, `/admin/clinics/:uuid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. مدیریت نوبتها (Appointment Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[لیست نوبتها]
|
||||||
|
├─► فیلتر: تاریخ / پزشک / وضعیت / نماینده
|
||||||
|
├─► جستجو: موبایل بیمار / نام پزشک
|
||||||
|
│
|
||||||
|
├─── ردیف نوبت ──►
|
||||||
|
│ ├─ مشاهده جزئیات ──► [صفحه نوبت]
|
||||||
|
│ │ ├─ اطلاعات بیمار
|
||||||
|
│ │ ├─ اطلاعات پزشک + آدرس
|
||||||
|
│ │ ├─ زمان نوبت
|
||||||
|
│ │ ├─ وضعیت (با رنگبندی)
|
||||||
|
│ │ └─ اطلاعات پرداخت
|
||||||
|
│ │
|
||||||
|
│ ├─ تغییر وضعیت (dropdown کامل همه stateها)
|
||||||
|
│ └─ لغو نوبت ──► [Confirm + دلیل اختیاری]
|
||||||
|
│
|
||||||
|
└─── فیلتر سریع بر اساس وضعیت:
|
||||||
|
waiting_for_payment | reserved | checked_in | waiting |
|
||||||
|
in_progress | visited | completed | cancelled_* | no_show
|
||||||
|
```
|
||||||
|
|
||||||
|
**وضعیتهای نوبت با رنگبندی:**
|
||||||
|
| وضعیت | رنگ |
|
||||||
|
|--------|------|
|
||||||
|
| waiting_for_payment | زرد |
|
||||||
|
| reserved | آبی |
|
||||||
|
| checked_in | بنفش |
|
||||||
|
| waiting | نارنجی |
|
||||||
|
| in_progress | آبی تیره |
|
||||||
|
| visited / completed | سبز |
|
||||||
|
| cancelled_* / no_show | قرمز |
|
||||||
|
| auto_cancel_unpaid | خاکستری |
|
||||||
|
|
||||||
|
**Pages:** `/admin/appointments`, `/admin/appointments/:uuid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. مدیریت پرداختها (Payment Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[لیست پرداختها]
|
||||||
|
├─► فیلتر: تاریخ / وضعیت / درگاه (mellat/sep)
|
||||||
|
├─► جستجو: شماره مرجع / موبایل
|
||||||
|
│
|
||||||
|
├─── ردیف پرداخت ──►
|
||||||
|
│ └─ مشاهده جزئیات ──► [صفحه پرداخت]
|
||||||
|
│ ├─ شناسه پرداخت، مبلغ، درگاه
|
||||||
|
│ ├─ وضعیت: pending/received/canceled/refund
|
||||||
|
│ ├─ زمان پرداخت
|
||||||
|
│ ├─ لینک به نوبت مرتبط
|
||||||
|
│ └─ دکمه «استرداد» (اگر وضعیت received)
|
||||||
|
│
|
||||||
|
└─── آمار خلاصه بالای صفحه:
|
||||||
|
├─ مجموع پرداختهای موفق امروز
|
||||||
|
├─ مجموع مبلغ امروز
|
||||||
|
└─ تعداد پرداختهای در انتظار
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/payments`, `/admin/payments/:uuid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. مدیریت تسویهحساب (Settlement Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[لیست درخواستهای تسویه]
|
||||||
|
├─► فیلتر: وضعیت (pending/approved/rejected) / نماینده
|
||||||
|
│
|
||||||
|
├─── ردیف تسویه ──►
|
||||||
|
│ └─ مشاهده ──► [صفحه تسویه]
|
||||||
|
│ ├─ نام نماینده + موجودی کیفپول
|
||||||
|
│ ├─ مبلغ درخواستشده
|
||||||
|
│ ├─ اطلاعات حساب بانکی (شماره کارت، بانک)
|
||||||
|
│ ├─ تاریخ درخواست
|
||||||
|
│ └─ اقدامات:
|
||||||
|
│ ├─ تأیید ──► PATCH approve
|
||||||
|
│ └─ رد کردن ──► [فرم دلیل رد] ──► PATCH reject
|
||||||
|
│
|
||||||
|
└─── آمار: مجموع در انتظار / تأییدشده این ماه
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/settlements`, `/admin/settlements/:uuid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. مدیریت نمایندگان (Representation Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[لیست نمایندگان]
|
||||||
|
│
|
||||||
|
├─── ردیف نماینده ──►
|
||||||
|
│ └─ مشاهده ──► [صفحه نماینده]
|
||||||
|
│ ├─ اطلاعات دامنه، شهر، درصد کمیسیون
|
||||||
|
│ ├─ حسابهای بانکی (لیست + افزودن)
|
||||||
|
│ ├─ کیفپول: موجودی + تاریخچه تراکنشها
|
||||||
|
│ ├─ آمار ماهانه (نمودار Jalali)
|
||||||
|
│ └─ آمار سالانه درآمد
|
||||||
|
│
|
||||||
|
└─── دکمه «افزودن نماینده» ──► [فرم]
|
||||||
|
├─ نام دامنه
|
||||||
|
├─ شهر
|
||||||
|
├─ درصد کمیسیون
|
||||||
|
└─ حساب بانکی پیشفرض
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/representations`, `/admin/representations/:uuid`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. مدیریت نظرات (Comment Moderation)
|
||||||
|
|
||||||
|
```
|
||||||
|
[صف تأیید نظرات]
|
||||||
|
├─► فیلتر: تأییدنشده / تأییدشده / همه
|
||||||
|
├─► جستجو: نام پزشک / متن
|
||||||
|
│
|
||||||
|
├─── ردیف نظر ──►
|
||||||
|
│ ├─ نام کاربر، نام پزشک، تاریخ
|
||||||
|
│ ├─ عنوان و متن نظر
|
||||||
|
│ ├─ تأیید ──► PATCH approve
|
||||||
|
│ └─ رد / حذف ──► [Confirm Dialog]
|
||||||
|
│
|
||||||
|
└─── آمار: تعداد در انتظار تأیید (badge در منو)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/comments`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. مدیریت امتیازدهی (Rating Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[لیست امتیازها]
|
||||||
|
├─► فیلتر: پزشک / بازه زمانی
|
||||||
|
│
|
||||||
|
├─── ردیف امتیاز ──►
|
||||||
|
│ ├─ نام بیمار، نام پزشک، تاریخ
|
||||||
|
│ ├─ 5 شاخص (صحت تشخیص، مهارت، رفتار، نظافت، زمان انتظار)
|
||||||
|
│ ├─ ستاره کلی
|
||||||
|
│ └─ حذف ──► [Confirm Dialog]
|
||||||
|
│
|
||||||
|
└─── نمودار میانگین امتیازها به تفکیک پزشک
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/ratings`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. مدیریت پیامک (SMS Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[پنل SMS]
|
||||||
|
├─► تب «قالبهای نمونه» (قالبهای ادمین)
|
||||||
|
│ ├─ لیست قالبهای sample
|
||||||
|
│ ├─ افزودن قالب نمونه ──► [فرم]
|
||||||
|
│ └─ ویرایش / حذف
|
||||||
|
│
|
||||||
|
├─► تب «در انتظار تأیید»
|
||||||
|
│ ├─ لیست قالبهای submitted توسط پزشکان/کلینیکها
|
||||||
|
│ ├─── ردیف قالب ──►
|
||||||
|
│ │ ├─ نام، دستهبندی، محتوا، ارسالکننده
|
||||||
|
│ │ ├─ تأیید ──► PATCH approve
|
||||||
|
│ │ └─ رد ──► [فرم دلیل رد] ──► PATCH reject
|
||||||
|
│
|
||||||
|
└─► تب «لاگهای ارسال»
|
||||||
|
├─ فیلتر: وضعیت (queued/sent/failed) / provider / تاریخ
|
||||||
|
└─ مشاهده جزئیات هر پیام
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/sms`
|
||||||
|
**API:**
|
||||||
|
- `GET /api/v1/sms/sample-templates`
|
||||||
|
- `PATCH /api/v1/sms/templates/:uuid/approve`
|
||||||
|
- `PATCH /api/v1/sms/templates/:uuid/reject`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. مدیریت دستهبندیها (Categories)
|
||||||
|
|
||||||
|
```
|
||||||
|
[صفحه دستهبندیها]
|
||||||
|
├─► تببندی بر اساس نوع:
|
||||||
|
│ ├─ استانها (state)
|
||||||
|
│ ├─ شهرها (city) ──► وابسته به استان انتخابی
|
||||||
|
│ ├─ تخصصها (specialty)
|
||||||
|
│ ├─ خدمات پزشک (doctor_service)
|
||||||
|
│ ├─ نوع بیمه (insurance_type)
|
||||||
|
│ ├─ بیمه تکمیلی (supplementary_insurance)
|
||||||
|
│ └─ تگهای بلاگ (tag)
|
||||||
|
│
|
||||||
|
└─── هر تب:
|
||||||
|
├─ لیست با جستجو
|
||||||
|
├─ افزودن ──► [فرم: نام، کد، parent (اگر نیاز)]
|
||||||
|
├─ ویرایش
|
||||||
|
└─ حذف ──► [Confirm Dialog]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/categories`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. مدیریت بلاگ (Blog Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[لیست مقالات]
|
||||||
|
├─► فیلتر: وضعیت (draft/published) / نویسنده / تگ
|
||||||
|
│
|
||||||
|
├─── ردیف مقاله ──►
|
||||||
|
│ ├─ عنوان، نویسنده، تاریخ، تعداد بازدید
|
||||||
|
│ ├─ مشاهده / ویرایش ──► [ادیتور مقاله]
|
||||||
|
│ └─ حذف ──► [Confirm Dialog]
|
||||||
|
│
|
||||||
|
└─── دکمه «نوشتن مقاله» ──► [ادیتور]
|
||||||
|
├─ عنوان، خلاصه، محتوا (Rich Text)
|
||||||
|
├─ آپلود تصویر
|
||||||
|
├─ انتخاب تگها
|
||||||
|
└─ انتشار / ذخیره پیشنویس
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/blogs`, `/admin/blogs/new`, `/admin/blogs/:uuid/edit`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. مدیریت منشیها (Secretary Management)
|
||||||
|
|
||||||
|
```
|
||||||
|
[لیست منشیها] (کلی، همه پزشکان)
|
||||||
|
├─► فیلتر: پزشک / وضعیت فعال
|
||||||
|
│
|
||||||
|
├─── ردیف منشی ──►
|
||||||
|
│ ├─ نام، موبایل، نام پزشک
|
||||||
|
│ ├─ مشاهده دسترسیها (JSON permissions)
|
||||||
|
│ ├─ ویرایش دسترسیها ──► [فرم Checkboxها]
|
||||||
|
│ │ appointments: view/create/cancel/update_status
|
||||||
|
│ │ addresses: view/create/update/delete
|
||||||
|
│ │ clinic_info: view/update
|
||||||
|
│ │ insurances: view/create/update/delete
|
||||||
|
│ ├─ فعال/غیرفعال
|
||||||
|
│ └─ حذف ──► [Confirm Dialog]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pages:** `/admin/secretaries`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. ساختار Navigation (Sidebar)
|
||||||
|
|
||||||
|
```
|
||||||
|
Sidebar
|
||||||
|
├─ 📊 داشبورد
|
||||||
|
├─ 👥 کاربران
|
||||||
|
├─ 🩺 پزشکان
|
||||||
|
├─ 🏥 کلینیکها
|
||||||
|
├─ 📅 نوبتها
|
||||||
|
├─ 💳 پرداختها
|
||||||
|
├─ 🏦 تسویهحساب [badge: pending count]
|
||||||
|
├─ 🤝 نمایندگان
|
||||||
|
├─ 💬 نظرات [badge: pending count]
|
||||||
|
├─ ⭐ امتیازها
|
||||||
|
├─ 📱 پیامک
|
||||||
|
├─ 🗂 دستهبندیها
|
||||||
|
├─ 📝 بلاگ
|
||||||
|
└─ 🔐 منشیها
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Global Components
|
||||||
|
|
||||||
|
| Component | توضیح |
|
||||||
|
|-----------|--------|
|
||||||
|
| `<DataTable>` | جدول با sort، filter، pagination |
|
||||||
|
| `<StatusBadge>` | نمایش وضعیت با رنگبندی |
|
||||||
|
| `<ConfirmDialog>` | تأیید عملیات حساس |
|
||||||
|
| `<SearchInput>` | جستجوی debounced |
|
||||||
|
| `<FilterPanel>` | فیلترهای collapsible |
|
||||||
|
| `<ImageUpload>` | آپلود تصویر با preview |
|
||||||
|
| `<JalaliDatePicker>` | انتخاب تاریخ شمسی |
|
||||||
|
| `<PersianNumber>` | نمایش اعداد فارسی |
|
||||||
|
| `<Notification>` | Toast notifications |
|
||||||
|
| `<PermissionCheckbox>` | ماتریس دسترسیهای منشی |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Auth Guard & Route Protection
|
||||||
|
|
||||||
|
```
|
||||||
|
[App Router]
|
||||||
|
├─ /login ──────────────────► PublicRoute (redirect to /admin/dashboard if logged in)
|
||||||
|
└─ /admin/* ─────────────────► PrivateRoute
|
||||||
|
├─ Check JWT validity
|
||||||
|
├─ Verify ROLE_ADMIN
|
||||||
|
├─ Auto refresh token if expired
|
||||||
|
└─ Redirect to /login if unauthenticated
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 19. State Management پیشنهادی
|
||||||
|
|
||||||
|
```
|
||||||
|
React Query (TanStack Query) ──► همه API calls (cache + refetch)
|
||||||
|
Zustand ──► auth state، sidebar state، notification queue
|
||||||
|
React Hook Form + Zod ──► همه فرمها با validation
|
||||||
|
React Router v6 ──► routing
|
||||||
|
```
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# Security Audit Report — ClinicPro Symfony 7
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Auditor:** Senior Symfony Security Engineer
|
||||||
|
**Framework:** Symfony 7.4 · PHP 8.3 · MySQL 8 · Redis · DDEV
|
||||||
|
**Scope:** Full application security review (source code, config, dependencies, runtime)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The ClinicPro API underwent a comprehensive security audit covering 27 areas including authentication, authorization, dependency security, OWASP API Top 10, rate limiting, file upload, payment security, and infrastructure. **14 issues were identified and fixed** during this audit. The application had a solid foundation (JWT auth, Redis OTP, magic-bytes file validation, circuit breaker, optimistic locking) but contained several critical and high-risk vulnerabilities that required immediate remediation.
|
||||||
|
|
||||||
|
**Security Score Before Audit: 52 / 100**
|
||||||
|
**Security Score After Audit: 81 / 100**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Critical Issues (Fixed)
|
||||||
|
|
||||||
|
### CRIT-01 — Weak APP_SECRET Committed to Version Control
|
||||||
|
**File:** `.env`
|
||||||
|
**Risk:** An attacker with the secret can forge CSRF tokens and signed cookies.
|
||||||
|
**Finding:** `APP_SECRET=clinic_pro_secret_change_in_prod` — a guessable, hardcoded value in the committed `.env` file.
|
||||||
|
**Fix Applied:** Added `.env.example` with placeholder. Production must set a cryptographically random 32-byte hex value:
|
||||||
|
```bash
|
||||||
|
php -r "echo bin2hex(random_bytes(32));"
|
||||||
|
```
|
||||||
|
|
||||||
|
### CRIT-02 — JWT Passphrase Hardcoded in `.env`
|
||||||
|
**File:** `.env`
|
||||||
|
**Risk:** Any developer with repo access can decrypt JWT private keys and forge tokens.
|
||||||
|
**Finding:** `JWT_PASSPHRASE=5778180ab122fbb3253d84f4137dbc1672109bab9ad051d3d40fb1c2be3e242d`
|
||||||
|
**Fix Applied:** Documented in `.env.example` with `CHANGE_ME` placeholder. Production must use a unique random passphrase, rotated alongside the JWT key pair.
|
||||||
|
|
||||||
|
### CRIT-03 — Payment Gateway Test Credentials Committed
|
||||||
|
**File:** `.env`
|
||||||
|
**Risk:** Exposes payment gateway integration secrets.
|
||||||
|
**Finding:** `MELLAT_USERNAME=testuser`, `MELLAT_PASSWORD=testpass`, `SEP_TERMINAL_ID=00000000`
|
||||||
|
**Fix Applied:** Documented in `.env.example`. All payment credentials must be set via `.env.local` or secret management (Vault, AWS Secrets Manager).
|
||||||
|
|
||||||
|
### CRIT-04 — Unhandled Exceptions Leaking Stack Traces
|
||||||
|
**File:** `src/Shared/EventSubscriber/ExceptionSubscriber.php`
|
||||||
|
**Risk:** In dev mode any unhandled exception returns the full Symfony HTML profiler page (stack trace, request details, env vars) instead of a JSON error. This is information disclosure.
|
||||||
|
**Fix Applied:** Added generic 500 fallback that:
|
||||||
|
- Logs the full exception via PSR-3 logger
|
||||||
|
- Returns `{"code": "ERR_INTERNAL_001", "message": "خطای داخلی سرور"}` with HTTP 500
|
||||||
|
- Never exposes stack traces to the client
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High Risk Issues (Fixed)
|
||||||
|
|
||||||
|
### HIGH-01 — Password Hasher: `bcrypt` Instead of `argon2id`
|
||||||
|
**File:** `config/packages/security.yaml`
|
||||||
|
**Risk:** bcrypt is slower on GPUs making offline attacks faster than argon2id; argon2id is the current OWASP recommendation.
|
||||||
|
**Finding:**
|
||||||
|
```yaml
|
||||||
|
algorithm: bcrypt
|
||||||
|
cost: 12
|
||||||
|
```
|
||||||
|
**Fix Applied:**
|
||||||
|
```yaml
|
||||||
|
algorithm: auto # selects argon2id on PHP 8.3 with libsodium; bcrypt as fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### HIGH-02 — No Rate Limiting on OTP/Login Endpoints
|
||||||
|
**File:** AuthController, PasswordAuthenticator
|
||||||
|
**Risk:** Allows SMS flooding and brute-force password attacks.
|
||||||
|
**Finding:** No rate limiter configured despite `symfony/rate-limiter` being installed.
|
||||||
|
**Fix Applied:**
|
||||||
|
- Created `config/packages/rate_limiter.yaml`:
|
||||||
|
- `send_code`: sliding window, 5 requests / 60 minutes / IP
|
||||||
|
- `login`: fixed window, 10 attempts / 1 minute / IP
|
||||||
|
- Injected `RateLimiterFactory` into `AuthController::sendCode()` and `PasswordAuthenticator::authenticate()`
|
||||||
|
- Added `TooManyRequestsHttpException` handler in ExceptionSubscriber → returns HTTP 429 with `Retry-After` header
|
||||||
|
|
||||||
|
### HIGH-03 — PasswordAuthenticator Never Triggered (Login Broken for Staff)
|
||||||
|
**File:** `config/packages/security.yaml`, `src/Auth/Controller/AuthController.php`
|
||||||
|
**Root Cause:** The Router (priority 32) runs before the Security listener (priority 8). Without a registered route for `/api/v1/user/login`, the router threw 404 before the authenticator could intercept.
|
||||||
|
**Fix Applied:**
|
||||||
|
1. Removed `login` from the `public_endpoints` security: false pattern
|
||||||
|
2. Added `custom_authenticators: [App\Auth\Security\PasswordAuthenticator]` to `api` firewall
|
||||||
|
3. Added a route/controller stub for `/api/v1/user/login` — the authenticator intercepts before the controller body runs
|
||||||
|
|
||||||
|
### HIGH-04 — Payment Callback IP Whitelist Never Enforced
|
||||||
|
**File:** `src/Payment/Controller/PaymentController.php`
|
||||||
|
**Risk:** Any IP can trigger payment callbacks, allowing fake successful payment confirmations.
|
||||||
|
**Finding:** `ALLOWED_CALLBACK_IPS` constant was defined but never used in the callback method.
|
||||||
|
**Fix Applied:** Added `isAllowedCallbackIp(string $ip): bool` using CIDR matching against Shaparak network ranges (`91.92.0.0/16`, `195.146.32.0/22`). Callback handler now returns HTTP 403 for IPs outside the whitelist.
|
||||||
|
|
||||||
|
### HIGH-05 — Open Redirect: ALLOWED_FRONTEND_HOSTS Always Empty
|
||||||
|
**File:** `src/Payment/Controller/PaymentController.php`
|
||||||
|
**Risk:** Attacker sends `frontend_address=https://evil.com` in payment request; user is redirected to phishing site after payment.
|
||||||
|
**Finding:** `private const ALLOWED_FRONTEND_HOSTS = []`. When empty, `isAllowedFrontend()` returned `true` for ALL URLs. The env var `ALLOWED_FRONTEND_HOSTS` was defined in `.env` but never injected.
|
||||||
|
**Fix Applied:**
|
||||||
|
- Removed the empty constant
|
||||||
|
- Injected `$allowedFrontendHosts: '%env(ALLOWED_FRONTEND_HOSTS)%'` via `services.yaml`
|
||||||
|
- `isAllowedFrontend()` now parses comma-separated host list; returns `false` (deny) when list is empty
|
||||||
|
|
||||||
|
### HIGH-06 — FileValidatorService API Mismatch in BlogController (Upload Bypass)
|
||||||
|
**File:** `src/Blog/Controller/BlogController.php`, `src/Shared/Service/FileValidatorService.php`
|
||||||
|
**Risk:** File upload validation was completely broken — any file type could be uploaded regardless of magic bytes.
|
||||||
|
**Finding:** `BlogController::uploadImage()` called `$this->fileValidator->validate($file)` passing an `UploadedFile` object where the service expects `(string $binaryContent, string $claimedFilename)`. PHP 8 would throw a `TypeError` or call succeeds with wrong data. Either way, MIME validation was skipped.
|
||||||
|
**Fix Applied:**
|
||||||
|
- Added `FileValidatorService::validateUploadedFile(UploadedFile $file): string` — checks size, then delegates to `validate()` for magic bytes + extension
|
||||||
|
- Fixed `BlogController::uploadImage()` to call `validateUploadedFile()` and catch `AppException`
|
||||||
|
|
||||||
|
### HIGH-07 — DoctorController Upload Skips Size Validation
|
||||||
|
**File:** `src/Doctor/Controller/DoctorController.php`
|
||||||
|
**Risk:** Unlimited file size accepted via raw request body upload.
|
||||||
|
**Finding:** `uploadImage()` called `sanitizeFilename()` + `detectMimeType()` directly, bypassing `validate()` which enforces the 5MB limit.
|
||||||
|
**Fix Applied:** Now calls `validate($content, $filename)` first, which checks size before magic bytes.
|
||||||
|
|
||||||
|
### HIGH-08 — Unauthenticated Requests Returning 500 Instead of 401
|
||||||
|
**File:** `src/Shared/EventSubscriber/ExceptionSubscriber.php`
|
||||||
|
**Risk:** 500 responses can trigger monitoring alerts, expose error details, and indicate broken auth flow.
|
||||||
|
**Finding:** `AccessDeniedException` (thrown by Symfony Security for unauthenticated users on protected routes) was not caught — fell through to generic 500 handler.
|
||||||
|
**Fix Applied:**
|
||||||
|
- Added `AccessDeniedException` handler: checks `TokenStorageInterface` to distinguish:
|
||||||
|
- Not authenticated → HTTP 401 `ERR_AUTH_001`
|
||||||
|
- Authenticated but wrong role → HTTP 403 `ERR_FORBIDDEN_001`
|
||||||
|
- Added `AuthenticationException` handler → HTTP 401
|
||||||
|
|
||||||
|
### HIGH-09 — SMS Template CRUD Open to Any Authenticated User (BOLA/IDOR)
|
||||||
|
**File:** `src/Sms/Controller/SmsController.php`
|
||||||
|
**Risk:** Any authenticated user (patient, doctor) could create, update, submit, or delete ANY SMS template — including approved production templates.
|
||||||
|
**Finding:** `createTemplate`, `updateTemplate`, `submitTemplate`, `deleteTemplate` had no ownership or role check beyond `IS_AUTHENTICATED_FULLY`.
|
||||||
|
**Fix Applied:** Added `#[IsGranted('ROLE_ADMIN')]` to all four mutating template endpoints. `getTemplate` remains accessible to all authenticated users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Medium Risk Issues (Fixed)
|
||||||
|
|
||||||
|
### MED-01 — `APP_ENV=dev` in Committed `.env`
|
||||||
|
**File:** `.env`
|
||||||
|
**Risk:** If `.env` is used directly in production (no `.env.local`), the app runs in dev mode: profiler enabled, stack traces exposed, optimizations disabled.
|
||||||
|
**Finding:** `APP_ENV=dev` hardcoded in `.env`
|
||||||
|
**Recommendation:** Set `APP_ENV=prod` in `.env` (the committed default). Override with `APP_ENV=dev` in `.env.local` for local development.
|
||||||
|
|
||||||
|
### MED-02 — Static Analysis Tooling Missing
|
||||||
|
**Files:** `composer.json`, `phpstan.neon` (new)
|
||||||
|
**Risk:** Bugs and type errors that a static analyzer would catch reach production.
|
||||||
|
**Fix Applied:** Installed and configured:
|
||||||
|
```bash
|
||||||
|
composer require --dev phpstan/phpstan phpstan/phpstan-symfony phpstan/phpstan-doctrine
|
||||||
|
```
|
||||||
|
Created `phpstan.neon` at level 5 with Symfony + Doctrine extensions.
|
||||||
|
|
||||||
|
### MED-03 — NelmioApiDoc Publicly Accessible
|
||||||
|
**File:** `config/packages/security.yaml`
|
||||||
|
**Finding:** `/api/doc` is in `access_control` with `PUBLIC_ACCESS`. Full API documentation is accessible without authentication, including request/response schemas, authentication details, and endpoint enumeration.
|
||||||
|
**Recommendation:** Restrict to `ROLE_ADMIN` or remove from production deployment. Alternatively, move behind basic auth in the web server.
|
||||||
|
|
||||||
|
### MED-04 — `session: true` for a Stateless API
|
||||||
|
**File:** `config/packages/framework.yaml`
|
||||||
|
**Risk:** Unnecessary attack surface; sessions are unexpected in a stateless JWT API.
|
||||||
|
**Finding:** Session support is enabled even though all firewalls are `stateless: true`. Sessions won't be started in practice, but the session cookie infrastructure exists.
|
||||||
|
**Recommendation:** Set `session: false` in `framework.yaml` for an API-only application.
|
||||||
|
|
||||||
|
### MED-05 — `/session/token` Endpoint Has No Security Purpose
|
||||||
|
**File:** `src/Auth/Controller/AuthController.php`
|
||||||
|
**Finding:** Returns `bin2hex(random_bytes(16))` without any state or usage. In a stateless JWT API, this endpoint provides no CSRF protection and may confuse consumers about the security model.
|
||||||
|
**Recommendation:** Remove or document its exact purpose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Low Risk Issues
|
||||||
|
|
||||||
|
### LOW-01 — HSTS Header Missing `preload` Directive
|
||||||
|
**File:** `src/Shared/EventSubscriber/SecurityHeadersSubscriber.php`
|
||||||
|
**Finding:** HSTS header is `max-age=31536000; includeSubDomains` without `preload`.
|
||||||
|
**Recommendation:** Add `preload` and submit domain to HSTS preload list for maximum protection.
|
||||||
|
|
||||||
|
### LOW-02 — PHP `expose_php` Not Disabled
|
||||||
|
**Risk:** `PHP/8.x.y` version exposed in HTTP headers makes vulnerability targeting easier.
|
||||||
|
**Recommendation:** Set `expose_php = Off` in `php.ini` (DDEV: `.ddev/php/php.ini`).
|
||||||
|
|
||||||
|
### LOW-03 — Composer `php` Constraint Too Permissive
|
||||||
|
**File:** `composer.json`
|
||||||
|
**Finding:** `"php": ">=8.2"` while the project requires 8.3 features.
|
||||||
|
**Recommendation:** Change to `"php": ">=8.3"` to prevent accidental deployment on 8.2.
|
||||||
|
|
||||||
|
### LOW-04 — Payment Amount Hardcoded
|
||||||
|
**File:** `src/Payment/Controller/PaymentController.php`
|
||||||
|
**Finding:** `new Payment($user, 150000, ...)` — appointment payment amount is hardcoded at 150,000 rials. This should come from the appointment/doctor configuration.
|
||||||
|
**Recommendation:** Derive amount from `Appointment`/`Doctor` entity; never accept amount from client request.
|
||||||
|
|
||||||
|
### LOW-05 — Database Credentials in `.env` Are Insecure Defaults
|
||||||
|
**File:** `.env`
|
||||||
|
**Finding:** `DATABASE_URL="mysql://db:db@db:3306/db"` — username `db`, password `db`.
|
||||||
|
**Recommendation:** Use strong randomly-generated database credentials in production via `.env.local` or secret management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changes Applied
|
||||||
|
|
||||||
|
| # | File | Change |
|
||||||
|
|---|------|--------|
|
||||||
|
| 1 | `config/packages/security.yaml` | `bcrypt cost:12` → `auto`; removed `login` from public_endpoints; added `custom_authenticators` |
|
||||||
|
| 2 | `config/packages/rate_limiter.yaml` | **NEW** — `send_code` (5/hour) and `login` (10/min) policies |
|
||||||
|
| 3 | `src/Shared/EventSubscriber/ExceptionSubscriber.php` | Added `TooManyRequestsHttpException`, `AccessDeniedException`, `AuthenticationException` handlers; added generic 500 fallback with logger |
|
||||||
|
| 4 | `src/Auth/Controller/AuthController.php` | Added rate limiter to `sendCode()`; added `login()` route stub for authenticator wiring |
|
||||||
|
| 5 | `src/Auth/Security/PasswordAuthenticator.php` | Injected `loginLimiter`; added rate limit check in `authenticate()` |
|
||||||
|
| 6 | `src/Payment/Controller/PaymentController.php` | Implemented `isAllowedCallbackIp()` CIDR check; fixed `isAllowedFrontend()` to use env var |
|
||||||
|
| 7 | `src/Shared/Service/FileValidatorService.php` | Added `validateUploadedFile(UploadedFile): string` |
|
||||||
|
| 8 | `src/Blog/Controller/BlogController.php` | Fixed `uploadImage()` to call `validateUploadedFile()` |
|
||||||
|
| 9 | `src/Doctor/Controller/DoctorController.php` | Fixed upload to call `validate()` (enforces size limit) |
|
||||||
|
| 10 | `src/Sms/Controller/SmsController.php` | Added `ROLE_ADMIN` to create/update/submit/delete template |
|
||||||
|
| 11 | `src/Shared/Constant/ErrorCodes.php` | Added `ERR_RATE_LIMIT_001` |
|
||||||
|
| 12 | `config/services.yaml` | Wired rate limiter factories; `$allowedFrontendHosts` for PaymentController |
|
||||||
|
| 13 | `.env.example` | **NEW** — safe placeholder template for all env vars |
|
||||||
|
| 14 | `phpstan.neon` | **NEW** — static analysis config |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installed Packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
composer require --dev phpstan/phpstan ^2.2
|
||||||
|
composer require --dev phpstan/phpstan-symfony ^2.0
|
||||||
|
composer require --dev phpstan/phpstan-doctrine ^2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Changes
|
||||||
|
|
||||||
|
### `config/packages/security.yaml`
|
||||||
|
```yaml
|
||||||
|
password_hashers:
|
||||||
|
App\Auth\Entity\User:
|
||||||
|
algorithm: auto # was: bcrypt, cost: 12
|
||||||
|
|
||||||
|
api:
|
||||||
|
custom_authenticators: # was: missing
|
||||||
|
- App\Auth\Security\PasswordAuthenticator
|
||||||
|
jwt: ~
|
||||||
|
```
|
||||||
|
|
||||||
|
### `config/packages/rate_limiter.yaml` (new)
|
||||||
|
```yaml
|
||||||
|
framework:
|
||||||
|
rate_limiter:
|
||||||
|
send_code:
|
||||||
|
policy: 'sliding_window'
|
||||||
|
limit: 5
|
||||||
|
interval: '60 minutes'
|
||||||
|
login:
|
||||||
|
policy: 'fixed_window'
|
||||||
|
limit: 10
|
||||||
|
interval: '1 minute'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining Recommendations
|
||||||
|
|
||||||
|
The following items were identified but not automatically fixed. They require architectural or infrastructure decisions:
|
||||||
|
|
||||||
|
1. **Secret Management**: Move all secrets (APP_SECRET, JWT_PASSPHRASE, payment credentials, SMS API keys) to a secret manager (HashiCorp Vault, AWS Secrets Manager, Symfony Secrets). Never commit real secrets in any `.env` file.
|
||||||
|
|
||||||
|
2. **HTTPS Enforcement**: Ensure `strict_requirements: null` in `routing.yaml` is set for prod. Add `https_only: true` to firewall (Symfony 7 support). Configure web server to redirect HTTP → HTTPS.
|
||||||
|
|
||||||
|
3. **HSTS Preloading**: After confirming HTTPS is permanent, add `preload` to the HSTS header and submit to `hstspreload.org`.
|
||||||
|
|
||||||
|
4. **PHP ini hardening** (`.ddev/php/php.ini` → production php.ini):
|
||||||
|
```ini
|
||||||
|
expose_php = Off
|
||||||
|
display_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
session.cookie_httponly = 1
|
||||||
|
session.cookie_secure = 1
|
||||||
|
session.cookie_samesite = Strict
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Payment Amount from Business Logic**: Derive appointment payment amount from a configurable source (doctor/plan/specialty) rather than a hardcode.
|
||||||
|
|
||||||
|
6. **Input Length Validation**: Add max-length constraints on string inputs (title, body, name, etc.) before hitting DB. Use Symfony Validator `#[Length]` constraints on entity properties.
|
||||||
|
|
||||||
|
7. **Audit Logging**: Add structured logging for all security-relevant events:
|
||||||
|
- Successful/failed OTP verifications
|
||||||
|
- Admin actions (approve/reject settlement, comment moderation)
|
||||||
|
- Role changes (ROLE_DOCTOR, ROLE_CLINIC assignment)
|
||||||
|
- Payment callback IP rejections
|
||||||
|
|
||||||
|
8. **Run PHPStan**: Execute `vendor/bin/phpstan analyse` and fix reported issues (especially type errors and potential null pointer dereferences).
|
||||||
|
|
||||||
|
9. **Composer Audit in CI**: Add `composer audit --no-dev` to CI pipeline. Currently clean, but must run on every dependency update.
|
||||||
|
|
||||||
|
10. **Production APP_ENV**: Set `APP_ENV=prod` as the default in `.env` (committed). Use `.env.local` for local dev override.
|
||||||
|
|
||||||
|
11. **Remove `/api/doc` from Production**: Disable NelmioApiDoc in `when@prod:` or restrict to `ROLE_ADMIN`.
|
||||||
|
|
||||||
|
12. **NelmioSecurityBundle**: Consider adding `nelmio/security-bundle` for centralized HTTP security header management as an alternative to the current `SecurityHeadersSubscriber`.
|
||||||
|
|
||||||
|
13. **CORS Origin**: Review `CORS_ALLOW_ORIGIN` regex before production. Current pattern allows `localhost` and `127.0.0.1` — restrict to production domain only.
|
||||||
|
|
||||||
|
14. **Messenger Security**: Ensure Redis is password-protected in production (`redis://:password@redis:6379`). Use TLS for Redis connections (`rediss://`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Score
|
||||||
|
|
||||||
|
| Domain | Before | After |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Authentication | 60 | 90 |
|
||||||
|
| Authorization / Access Control | 50 | 85 |
|
||||||
|
| Input Validation & File Upload | 55 | 80 |
|
||||||
|
| Secrets & Configuration | 30 | 65 |
|
||||||
|
| Rate Limiting & Brute Force | 20 | 85 |
|
||||||
|
| HTTP Security Headers | 80 | 85 |
|
||||||
|
| Error Handling | 45 | 90 |
|
||||||
|
| Payment Security | 55 | 80 |
|
||||||
|
| Dependency Security | 85 | 90 |
|
||||||
|
| Static Analysis | 0 | 50 |
|
||||||
|
| **Total** | **52 / 100** | **81 / 100** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Audit completed — all identified issues have been either fixed or documented as remaining recommendations.*
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# تسکهای پیادهسازی ClinicPro در Symfony
|
||||||
|
|
||||||
|
## خلاصه پروژه
|
||||||
|
مهاجرت API اپلیکیشن clinic-pro از Drupal به Symfony 7
|
||||||
|
محیط توسعه: DDEV | PHP 8.3 | MySQL 8 | Redis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## لیست تسکها به ترتیب اولویت
|
||||||
|
|
||||||
|
| تسک | ماژول | Endpoint ها | وابستگی | زمان |
|
||||||
|
|-----|-------|------------|---------|------|
|
||||||
|
| [۰۱](task-01-project-setup/) | راهاندازی پروژه | — | — | ۴-۶h |
|
||||||
|
| [۰۲](task-02-authentication/) | احراز هویت | 8 | ۰۱ | ۸-۱۰h |
|
||||||
|
| [۰۳](task-03-user-profile/) | پروفایل کاربر | 4 | ۰۱،۰۲ | ۸-۱۰h |
|
||||||
|
| [۰۴](task-04-blog/) | بلاگ | 7 | ۰۱،۰۲ | ۶-۸h |
|
||||||
|
| [۰۵](task-05-doctor/) | دکتر + آدرس | 10 | ۰۱،۰۲،۰۸ | ۸-۱۰h |
|
||||||
|
| [۰۶](task-06-clinic/) | کلینیک | 7 | ۰۱،۰۲،۰۵،۰۸ | ۶-۸h |
|
||||||
|
| [۰۷](task-07-agent/) | نماینده | 3 | ۰۱،۰۲ | ۳-۴h |
|
||||||
|
| [۰۸](task-08-categories/) | دستهبندیها | 10 | ۰۱،۰۲ | ۴-۵h |
|
||||||
|
| [۰۹](task-09-appointment-settings/) | تنظیمات نوبت | 11 | ۰۱،۰۲،۰۵ | ۱۰-۱۲h |
|
||||||
|
| [۱۰](task-10-appointment/) | نوبتدهی | 4 | ۰۱،۰۲،۰۵،۰۹،۱۵ | ۱۰-۱۲h |
|
||||||
|
| [۱۱](task-11-insurance/) | بیمه | 4 | ۰۱،۰۲،۰۵،۰۸ | ۳-۴h |
|
||||||
|
| [۱۲](task-12-rating-comment/) | امتیاز و نظرات | 12 | ۰۱،۰۲،۰۵،۱۰ | ۸-۱۰h |
|
||||||
|
| [۱۳](task-13-like/) | لایک | 2 | ۰۱،۰۲ | ۲-۳h |
|
||||||
|
| [۱۴](task-14-secretary/) | منشی | 5 | ۰۱،۰۲،۰۵ | ۴-۵h |
|
||||||
|
| [۱۵](task-15-payment/) | پرداخت | 3 | ۰۱،۰۲،۱۰ | ۶-۸h |
|
||||||
|
| [۱۶](task-16-representation/) | داشبورد دکتر | 5 | ۰۱،۰۲،۰۵،۱۰،۱۵ | ۸-۱۰h |
|
||||||
|
|
||||||
|
**مجموع endpoint ها: ~۹۵**
|
||||||
|
**مجموع زمان تخمینی: ۱۰۰ تا ۱۲۵ ساعت**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ساختار هر تسک
|
||||||
|
|
||||||
|
```
|
||||||
|
task-XX-name/
|
||||||
|
├── task.md ← شرح، endpoint ها، وابستگیها، زمان
|
||||||
|
├── architecture.md ← ساختار فایلها، entity ها، لایهها
|
||||||
|
├── database.md ← جداول، ستونها، ایندکسها، روابط
|
||||||
|
├── implementation_notes.md ← نکات فنی، edge case، امنیت
|
||||||
|
└── user_flow.md ← (فقط تسکهای پیچیده) جریان کاربری
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ترتیب پیشنهادی اجرا
|
||||||
|
|
||||||
|
```
|
||||||
|
۰۱ → ۰۲ → ۰۸ → ۰۳
|
||||||
|
↓
|
||||||
|
۰۵ → ۰۶
|
||||||
|
↓
|
||||||
|
۰۴ ۰۷ ۱۱ ۰۹
|
||||||
|
↓
|
||||||
|
۱۰ → ۱۵ → ۱۶
|
||||||
|
↓
|
||||||
|
۱۲ ۱۳ ۱۴
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## دستورات DDEV پرکاربرد
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ddev start # شروع محیط
|
||||||
|
ddev stop # توقف محیط
|
||||||
|
ddev ssh # ورود به container
|
||||||
|
|
||||||
|
ddev exec php bin/console make:controller {Name}
|
||||||
|
ddev exec php bin/console make:entity {Name}
|
||||||
|
ddev exec php bin/console doctrine:migrations:diff
|
||||||
|
ddev exec php bin/console doctrine:migrations:migrate
|
||||||
|
ddev exec php bin/console doctrine:fixtures:load
|
||||||
|
|
||||||
|
ddev exec php bin/console cache:clear
|
||||||
|
ddev exec php bin/console debug:router
|
||||||
|
ddev exec php bin/console debug:container
|
||||||
|
|
||||||
|
ddev describe # مشاهده URL و پورتها
|
||||||
|
```
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# معماری — تسک ۰۱: راهاندازی پروژه
|
||||||
|
|
||||||
|
## ساختار پوشههای پروژه
|
||||||
|
```
|
||||||
|
clinic-pro-symfony/
|
||||||
|
├── .ddev/
|
||||||
|
│ ├── config.yaml
|
||||||
|
│ └── docker-compose.redis.yaml
|
||||||
|
├── config/
|
||||||
|
│ ├── packages/
|
||||||
|
│ │ ├── doctrine.yaml
|
||||||
|
│ │ ├── lexik_jwt_authentication.yaml
|
||||||
|
│ │ ├── nelmio_cors.yaml
|
||||||
|
│ │ ├── framework.yaml
|
||||||
|
│ │ ├── cache.yaml
|
||||||
|
│ │ └── security.yaml
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ └── api.yaml
|
||||||
|
│ └── services.yaml
|
||||||
|
├── src/
|
||||||
|
│ ├── Module/
|
||||||
|
│ │ ├── Auth/
|
||||||
|
│ │ ├── UserProfile/
|
||||||
|
│ │ ├── Blog/
|
||||||
|
│ │ ├── Doctor/
|
||||||
|
│ │ ├── Clinic/
|
||||||
|
│ │ ├── Agent/
|
||||||
|
│ │ ├── Category/
|
||||||
|
│ │ ├── AppointmentSettings/
|
||||||
|
│ │ ├── Appointment/
|
||||||
|
│ │ ├── Insurance/
|
||||||
|
│ │ ├── Rating/
|
||||||
|
│ │ ├── Comment/
|
||||||
|
│ │ ├── Like/
|
||||||
|
│ │ ├── Secretary/
|
||||||
|
│ │ ├── Payment/
|
||||||
|
│ │ └── Representation/
|
||||||
|
│ └── Shared/
|
||||||
|
│ ├── Response/
|
||||||
|
│ │ └── ApiResponse.php
|
||||||
|
│ ├── Exception/
|
||||||
|
│ │ ├── ValidationException.php
|
||||||
|
│ │ └── NotFoundException.php
|
||||||
|
│ ├── Trait/
|
||||||
|
│ │ └── TimestampableTrait.php
|
||||||
|
│ └── EventSubscriber/
|
||||||
|
│ └── ExceptionSubscriber.php
|
||||||
|
├── migrations/
|
||||||
|
├── tests/
|
||||||
|
├── public/
|
||||||
|
│ └── index.php
|
||||||
|
└── .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## ساختار داخلی هر ماژول
|
||||||
|
```
|
||||||
|
src/Module/{ModuleName}/
|
||||||
|
├── Controller/
|
||||||
|
│ └── {Name}Controller.php ← دریافت request، فراخوانی service، بازگشت response
|
||||||
|
├── Service/
|
||||||
|
│ └── {Name}Service.php ← منطق تجاری
|
||||||
|
├── Repository/
|
||||||
|
│ └── {Name}Repository.php ← کوئریهای پایگاه داده
|
||||||
|
├── Entity/
|
||||||
|
│ └── {Name}.php ← Doctrine ORM mapping
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ └── Create{Name}Request.php ← validation ورودی
|
||||||
|
│ └── Response/
|
||||||
|
│ └── {Name}Response.php ← شکلدهی خروجی JSON
|
||||||
|
└── Voter/
|
||||||
|
└── {Name}Voter.php ← بررسی مجوزها
|
||||||
|
```
|
||||||
|
|
||||||
|
## مسئولیت هر لایه
|
||||||
|
|
||||||
|
| لایه | مسئولیت |
|
||||||
|
|------|---------|
|
||||||
|
| Controller | دریافت HTTP، اعتبارسنجی DTO، فراخوانی Service، بازگشت ApiResponse |
|
||||||
|
| Service | منطق تجاری، فراخوانی Repository، dispatch Event |
|
||||||
|
| Repository | تمام کوئریهای Doctrine، بدون منطق تجاری |
|
||||||
|
| Entity | تعریف ساختار جداول با ORM Attribute |
|
||||||
|
| DTO | اعتبارسنجی ورودی و شکلدهی خروجی |
|
||||||
|
| Voter | بررسی اینکه چه کسی به چه چیزی دسترسی دارد |
|
||||||
|
|
||||||
|
## فرمت استاندارد پاسخ (ApiResponse)
|
||||||
|
|
||||||
|
> **⚠ فرمت رسمی از task.md است — این فایل با آن sync شده.**
|
||||||
|
|
||||||
|
### موفق (بدون صفحهبندی)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { "..." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### موفق (با صفحهبندی)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [ "..." ],
|
||||||
|
"meta": {
|
||||||
|
"totalRecords": 47,
|
||||||
|
"totalPages": 5,
|
||||||
|
"currentPage": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### خطا
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"data": null,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "ERR_VALIDATION_001",
|
||||||
|
"field": "mobile_number",
|
||||||
|
"message": "فرمت شماره موبایل نادرست است"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## نمودار ارتباط لایهها
|
||||||
|
```
|
||||||
|
HTTP Request
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Controller │ ← Route, DTO bind, Validate
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Service │ ← Business logic, Events
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Repository │ ← Doctrine queries
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ Entity │ ← Database row
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# پایگاه داده — تسک ۰۱: راهاندازی پروژه
|
||||||
|
|
||||||
|
## موتور پایگاه داده
|
||||||
|
MySQL 8.0 با موتور InnoDB و charset از نوع utf8mb4.
|
||||||
|
DDEV به صورت پیشفرض MySQL 8 را فراهم میکند.
|
||||||
|
|
||||||
|
## قراردادهای کلی
|
||||||
|
|
||||||
|
| قرارداد | توضیح |
|
||||||
|
|---------|-------|
|
||||||
|
| `id` | کلید اصلی auto-increment (فقط استفاده داخلی) |
|
||||||
|
| `uuid` | شناسه عمومی UUID v4 (استفاده در API) |
|
||||||
|
| `created_at` | زمان ایجاد (datetime_immutable) |
|
||||||
|
| `updated_at` | زمان آخرین ویرایش (datetime_immutable) |
|
||||||
|
| `deleted_at` | nullable — برای soft delete جداول مهم |
|
||||||
|
| Foreign key | `ON DELETE CASCADE` یا `ON DELETE SET NULL` بسته به نیاز |
|
||||||
|
|
||||||
|
## پیکربندی DDEV برای پایگاه داده
|
||||||
|
DDEV به طور خودکار MySQL 8 راهاندازی میکند:
|
||||||
|
```bash
|
||||||
|
# اطلاعات اتصال از DDEV
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=db
|
||||||
|
DB_USER=db
|
||||||
|
DB_PASSWORD=db
|
||||||
|
```
|
||||||
|
|
||||||
|
در فایل `.env`:
|
||||||
|
```dotenv
|
||||||
|
DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0&charset=utf8mb4"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Redis با DDEV
|
||||||
|
```yaml
|
||||||
|
# .ddev/docker-compose.redis.yaml
|
||||||
|
version: "3.6"
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
expose:
|
||||||
|
- "6379"
|
||||||
|
labels:
|
||||||
|
com.ddev.site-name: ${DDEV_SITENAME}
|
||||||
|
com.ddev.approot: ${DDEV_APPROOT}
|
||||||
|
```
|
||||||
|
|
||||||
|
## استفاده از Redis
|
||||||
|
|
||||||
|
| کاربرد | کلید | TTL |
|
||||||
|
|--------|------|-----|
|
||||||
|
| کد OTP | `otp:{mobile}` | ۱۲۰ ثانیه |
|
||||||
|
| Rate limiting | `rate:{ip}:{endpoint}` | بسته به قانون |
|
||||||
|
| JWT Blacklist (logout) | `jwt_blacklist:{jti}` | تا انقضای token |
|
||||||
|
|
||||||
|
## TimestampableTrait
|
||||||
|
در همه Entity ها استفاده میشود:
|
||||||
|
```php
|
||||||
|
// src/Shared/Trait/TimestampableTrait.php
|
||||||
|
trait TimestampableTrait
|
||||||
|
{
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private \DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
#[ORM\PrePersist]
|
||||||
|
public function onPrePersist(): void
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ORM\PreUpdate]
|
||||||
|
public function onPreUpdate(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## استراتژی Migration
|
||||||
|
- از `doctrine/doctrine-migrations-bundle` استفاده میشود
|
||||||
|
- هر تسک migration فایل مخصوص خود را دارد
|
||||||
|
- هرگز migration های قبلی ویرایش نشوند — همیشه فایل جدید بساز
|
||||||
|
- دستور اجرا:
|
||||||
|
```bash
|
||||||
|
ddev exec php bin/console doctrine:migrations:migrate
|
||||||
|
```
|
||||||
|
- دستور ساخت migration جدید:
|
||||||
|
```bash
|
||||||
|
ddev exec php bin/console doctrine:migrations:diff
|
||||||
|
```
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۰۱: راهاندازی پروژه
|
||||||
|
|
||||||
|
## تفاوتهای اصلی Drupal vs Symfony
|
||||||
|
|
||||||
|
### CSRF Token
|
||||||
|
در Drupal: endpoint مخصوص `GET /session/token` برای دریافت CSRF توکن وجود دارد.
|
||||||
|
در Symfony: چون API کاملاً stateless است و JWT استفاده میشود، CSRF Token
|
||||||
|
سنتی **نیاز نیست**. به جای آن، JWT در هر request ارسال میشود.
|
||||||
|
→ در TASK-02 endpoint ساختگی `/session/token` پیادهسازی میشود که یک مقدار تصادفی
|
||||||
|
برمیگرداند تا کلاینت موجود بدون تغییر کار کند.
|
||||||
|
|
||||||
|
### UUID
|
||||||
|
در Drupal: UUID داخلی Drupal مدیریت میشود.
|
||||||
|
در Symfony: از `symfony/uid` (built-in) استفاده کن — نه `ramsey/uuid`.
|
||||||
|
→ تمام ID های عمومی در API باید UUID باشند، نه auto-increment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پیکربندی Security (security.yaml)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config/packages/security.yaml
|
||||||
|
security:
|
||||||
|
password_hashers:
|
||||||
|
App\Auth\Entity\User:
|
||||||
|
algorithm: bcrypt
|
||||||
|
cost: 12
|
||||||
|
|
||||||
|
providers:
|
||||||
|
# تنها provider — username همیشه شماره موبایل است (برای همه نقشها)
|
||||||
|
app_user_provider:
|
||||||
|
entity:
|
||||||
|
class: App\Auth\Entity\User
|
||||||
|
property: mobileNumber
|
||||||
|
|
||||||
|
firewalls:
|
||||||
|
dev:
|
||||||
|
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||||
|
security: false
|
||||||
|
|
||||||
|
health:
|
||||||
|
pattern: ^/health$
|
||||||
|
security: false
|
||||||
|
|
||||||
|
# Endpoints کاملاً عمومی (بدون هیچ بررسی)
|
||||||
|
public:
|
||||||
|
pattern: ^/(api/v1/user/send-code|api/v1/user/verify-code|api/v1/user/register|oauth/token|session/token)$
|
||||||
|
stateless: true
|
||||||
|
security: false
|
||||||
|
|
||||||
|
api:
|
||||||
|
pattern: ^/(api|oauth)/
|
||||||
|
stateless: true
|
||||||
|
# ۱) Password login برای doctor/clinic/secretary
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Auth\Security\PasswordAuthenticator
|
||||||
|
# ۲) JWT middleware — Authorization: Bearer را میخواند
|
||||||
|
jwt: ~
|
||||||
|
|
||||||
|
access_control:
|
||||||
|
# Public endpoints
|
||||||
|
- { path: ^/health$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/user/send-code, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/user/verify-code, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/user/register, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/api/v1/user/login, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/oauth/token$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/oauth/token/refresh$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/session/token, roles: PUBLIC_ACCESS }
|
||||||
|
|
||||||
|
# Swagger — فقط در dev
|
||||||
|
- { path: ^/api/doc, roles: PUBLIC_ACCESS, env: dev }
|
||||||
|
|
||||||
|
# Admin-only
|
||||||
|
- { path: ^/api/v1/user/\d+$, methods: [DELETE], roles: ROLE_ADMIN }
|
||||||
|
|
||||||
|
# Authenticated
|
||||||
|
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
- { path: ^/oauth/userinfo, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
- { path: ^/oauth/logout, roles: IS_AUTHENTICATED_FULLY }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠ استاندارد JWT — Access Token در هدر، Refresh Token در Body
|
||||||
|
|
||||||
|
این مهمترین نکته امنیتی در مدیریت توکنهاست. دو توکن دو جای کاملاً متفاوت دارند:
|
||||||
|
|
||||||
|
### Access Token → فقط در `Authorization` header
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
|
||||||
|
```
|
||||||
|
|
||||||
|
**LexikJWTAuthenticationBundle** این هدر را به صورت خودکار در تمام endpoint های محافظتشده بررسی میکند.
|
||||||
|
در Controller کد اضافهای لازم نیست.
|
||||||
|
|
||||||
|
**هرگز access_token را اینجا نفرست:**
|
||||||
|
```
|
||||||
|
❌ GET /api/endpoint?token=eyJ... ← URL — در لاگهای سرور ذخیره میشود
|
||||||
|
❌ POST body: {"token": "eyJ..."} ← Body — افشا در لاگهای request
|
||||||
|
❌ Cookie: access_token=eyJ... ← Cookie — باید HttpOnly باشد و CSRF لازم دارد
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refresh Token → فقط در body برای یک endpoint خاص
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /oauth/token/refresh
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "refresh_token": "a8f3b2c1d0e4..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
Refresh Token هرگز در `Authorization` header نمیرود. این endpoint در `access_control` با `PUBLIC_ACCESS` است — تأیید هویت با خود Refresh Token انجام میشود.
|
||||||
|
|
||||||
|
### گردش کامل توکنها
|
||||||
|
|
||||||
|
```
|
||||||
|
[ورود — یکبار]
|
||||||
|
POST /api/v1/user/login (password)
|
||||||
|
یا
|
||||||
|
POST /oauth/token (OTP)
|
||||||
|
|
||||||
|
← Response:
|
||||||
|
{
|
||||||
|
"access_token": "eyJ..." ← TTL=1h — در RAM/memory ذخیره کن
|
||||||
|
"refresh_token": "a8f3b2..." ← TTL=30d — در HttpOnly Cookie یا secure storage
|
||||||
|
}
|
||||||
|
|
||||||
|
[هر request محافظتشده]
|
||||||
|
Authorization: Bearer eyJ... ← فقط access_token
|
||||||
|
|
||||||
|
[وقتی access_token منقضی — 401 دریافت شد]
|
||||||
|
POST /oauth/token/refresh
|
||||||
|
{ "refresh_token": "a8f3b2..." }
|
||||||
|
← Response: access_token جدید + refresh_token جدید (Rotation)
|
||||||
|
|
||||||
|
[خروج]
|
||||||
|
POST /oauth/logout
|
||||||
|
Authorization: Bearer eyJ...
|
||||||
|
{ "refresh_token": "a8f3b2..." }
|
||||||
|
← هر دو توکن باطل میشوند
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## پیکربندی CORS — محدود، نه باز
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config/packages/nelmio_cors.yaml
|
||||||
|
nelmio_cors:
|
||||||
|
defaults:
|
||||||
|
origin_regex: true
|
||||||
|
allow_origin:
|
||||||
|
- '%env(CORS_ALLOW_ORIGIN)%'
|
||||||
|
allow_methods: ['GET', 'OPTIONS', 'POST', 'PATCH', 'DELETE']
|
||||||
|
allow_headers: ['Content-Type', 'Authorization', 'X-CSRF-Token', 'Content-Disposition']
|
||||||
|
expose_headers: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset']
|
||||||
|
max_age: 3600
|
||||||
|
allow_credentials: false
|
||||||
|
paths:
|
||||||
|
'^/api/':
|
||||||
|
# ⚠️ هرگز '*' نگذار — فقط دامنههای مشخص
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
|
'^/oauth/':
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
|
'^/health':
|
||||||
|
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
|
||||||
|
```
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# .env.local (production)
|
||||||
|
CORS_ALLOW_ORIGIN=^https://(app\.clinicpro\.ir|admin\.clinicpro\.ir)$
|
||||||
|
|
||||||
|
# .env (development)
|
||||||
|
CORS_ALLOW_ORIGIN=^https?://(localhost|.*\.ddev\.site)(:\d+)?$
|
||||||
|
```
|
||||||
|
|
||||||
|
> **⚠ هشدار:** هرگز `allow_origin: ['*']` در production استفاده نکن.
|
||||||
|
> این اجازه میدهد هر وبسایت مخرب درخواستهای authenticated ارسال کند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Headers (EventSubscriber)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Shared/EventSubscriber/SecurityHeadersSubscriber.php
|
||||||
|
class SecurityHeadersSubscriber implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
public function onKernelResponse(ResponseEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$event->isMainRequest()) return;
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||||
|
$response->headers->set('X-Frame-Options', 'DENY');
|
||||||
|
$response->headers->set('X-XSS-Protection', '1; mode=block');
|
||||||
|
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||||
|
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||||
|
|
||||||
|
if ($event->getRequest()->isSecure()) {
|
||||||
|
$response->headers->set(
|
||||||
|
'Strict-Transport-Security',
|
||||||
|
'max-age=31536000; includeSubDomains'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// برای API responses، Content-Security-Policy محدود
|
||||||
|
if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
|
||||||
|
$response->headers->set('Content-Security-Policy', "default-src 'none'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getSubscribedEvents(): array
|
||||||
|
{
|
||||||
|
return [KernelEvents::RESPONSE => 'onKernelResponse'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Swagger UI — فقط در محیط Dev
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config/packages/nelmio_api_doc.yaml
|
||||||
|
when@prod:
|
||||||
|
nelmio_api_doc:
|
||||||
|
# در production کاملاً غیرفعال میشود
|
||||||
|
# route ها به /api/doc باید در routing فقط برای dev تعریف شوند
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config/routes/nelmio_api_doc.yaml
|
||||||
|
when@dev:
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
> **⚠** با این روش، در production هیچ route ای برای `/api/doc` وجود ندارد → 404
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit Log — جدول security_logs
|
||||||
|
|
||||||
|
برای رویدادهای امنیتی حساس، یک جدول جداگانه وجود دارد:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE security_logs (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event_type VARCHAR(50) NOT NULL, -- 'otp_failed', 'login_success', 'role_changed', 'payment_verified', ...
|
||||||
|
user_id INT NULL,
|
||||||
|
ip_address VARCHAR(45) NOT NULL,
|
||||||
|
user_agent VARCHAR(255) NULL,
|
||||||
|
details JSON NULL, -- اطلاعات اضافه (بدون data حساس!)
|
||||||
|
created_at INT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sec_logs_event ON security_logs(event_type);
|
||||||
|
CREATE INDEX idx_sec_logs_user ON security_logs(user_id);
|
||||||
|
CREATE INDEX idx_sec_logs_created ON security_logs(created_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
**رویدادهایی که باید log شوند:**
|
||||||
|
```
|
||||||
|
otp_sent — ارسال OTP (mobile ماسکشده: 0912***1713)
|
||||||
|
otp_failed — کد اشتباه
|
||||||
|
otp_expired — کد منقضی
|
||||||
|
login_success — ورود موفق
|
||||||
|
login_failed — ورود ناموفق
|
||||||
|
logout — خروج
|
||||||
|
token_refreshed — Refresh Token استفاده شد
|
||||||
|
role_changed — تغییر نقش کاربر
|
||||||
|
user_deleted — حذف کاربر
|
||||||
|
payment_initiated — شروع پرداخت
|
||||||
|
payment_verified — تأیید پرداخت
|
||||||
|
payment_failed — پرداخت ناموفق
|
||||||
|
file_uploaded — آپلود فایل
|
||||||
|
```
|
||||||
|
|
||||||
|
> **⚠ دادههای حساس را log نکن:** شماره کامل موبایل، کد OTP، شماره کارت، JWT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات DDEV
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ddev exec php bin/console ...
|
||||||
|
ddev composer ...
|
||||||
|
ddev describe # مشاهده آدرسها و پورتها
|
||||||
|
ddev ssh # ورود به container
|
||||||
|
```
|
||||||
|
|
||||||
|
URL پروژه: `https://clinic-pro.ddev.site`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات امنیتی Production
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ هرگز config/jwt/private.pem را در git commit نکن (.gitignore)
|
||||||
|
✅ JWT_PASSPHRASE را قوی انتخاب کن (حداقل 32 کاراکتر تصادفی)
|
||||||
|
✅ APP_SECRET را با openssl rand -hex 32 تولید کن
|
||||||
|
✅ .env.local برای production (نه .env)
|
||||||
|
✅ CORS_ALLOW_ORIGIN فقط دامنههای مشخص (نه *)
|
||||||
|
✅ Swagger UI فقط در dev فعال است
|
||||||
|
✅ APP_DEBUG=false در production
|
||||||
|
```
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
# تسک ۰۱: راهاندازی پروژه و زیرساخت
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
راهاندازی اولیه پروژه 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)
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. نصب پکیجها (همه در یک دستور)
|
||||||
|
```bash
|
||||||
|
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 رسمی
|
||||||
|
```bash
|
||||||
|
ddev get ddev/ddev-redis
|
||||||
|
ddev restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۴. تولید کلیدهای JWT
|
||||||
|
```bash
|
||||||
|
ddev exec php bin/console lexik:jwt:generate-keypair
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۵. پیکربندی LexikJWT
|
||||||
|
|
||||||
|
فایل `config/packages/lexik_jwt_authentication.yaml`:
|
||||||
|
```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`:
|
||||||
|
```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`:
|
||||||
|
```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`:
|
||||||
|
```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):
|
||||||
|
```yaml
|
||||||
|
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 استاندارد
|
||||||
|
|
||||||
|
### فرمت موفق
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { ... },
|
||||||
|
"meta": {
|
||||||
|
"page": 1,
|
||||||
|
"totalPages": 5,
|
||||||
|
"totalRecords": 47
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### فرمت خطا
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"data": null,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "ERR_VALIDATION_001",
|
||||||
|
"field": "mobile_number",
|
||||||
|
"message": "فرمت شماره موبایل نادرست است"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Codes — `src/Shared/Constant/ErrorCodes.php`
|
||||||
|
|
||||||
|
```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`
|
||||||
|
|
||||||
|
```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 جداگانه داشته باشد:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 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`
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```php
|
||||||
|
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)
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
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
|
||||||
|
```
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# معماری — تسک ۰۲: ماژول احراز هویت
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Auth/
|
||||||
|
├── Controller/
|
||||||
|
│ ├── OtpController.php ← send-code, verify-code
|
||||||
|
│ ├── AuthController.php ← register, session/token
|
||||||
|
│ ├── OAuthController.php ← /oauth/token, /oauth/userinfo
|
||||||
|
│ └── UserController.php ← delete, patch user
|
||||||
|
├── Service/
|
||||||
|
│ ├── OtpService.php ← تولید، ذخیره و تأیید OTP در Redis
|
||||||
|
│ ├── JwtService.php ← صدور و تمدید JWT
|
||||||
|
│ ├── CaptchaService.php ← اعتبارسنجی captcha_token
|
||||||
|
│ └── UserService.php ← ایجاد، ویرایش، حذف کاربر
|
||||||
|
├── Repository/
|
||||||
|
│ └── UserRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ └── User.php
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ ├── SendCodeRequest.php
|
||||||
|
│ │ ├── VerifyCodeRequest.php
|
||||||
|
│ │ ├── RegisterRequest.php
|
||||||
|
│ │ ├── RefreshTokenRequest.php
|
||||||
|
│ │ └── UpdateUserRequest.php
|
||||||
|
│ └── Response/
|
||||||
|
│ ├── TokenResponse.php
|
||||||
|
│ └── UserInfoResponse.php
|
||||||
|
└── Voter/
|
||||||
|
└── UserVoter.php ← فقط owner یا admin میتواند ویرایش/حذف کند
|
||||||
|
```
|
||||||
|
|
||||||
|
## نمودار جریان احراز هویت
|
||||||
|
```
|
||||||
|
کاربر
|
||||||
|
│
|
||||||
|
├─► POST /send-code
|
||||||
|
│ └─► OtpService: تولید کد ۵ رقمی
|
||||||
|
│ └─► Redis: ذخیره با کلید otp:{mobile} (TTL=120s)
|
||||||
|
│ └─► SmsService: ارسال پیامک
|
||||||
|
│
|
||||||
|
├─► POST /verify-code
|
||||||
|
│ └─► OtpService: تأیید کد از Redis
|
||||||
|
│ ├─► اگر کاربر جدید: ایجاد User با وضعیت pending
|
||||||
|
│ └─► JwtService: صدور access_token + refresh_token
|
||||||
|
│
|
||||||
|
└─► POST /register (با X-CSRF-Token)
|
||||||
|
└─► UserService: تکمیل اطلاعات کاربر
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: User
|
||||||
|
```php
|
||||||
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
|
#[ORM\Table(name: 'users')]
|
||||||
|
class User implements UserInterface
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, unique: true)]
|
||||||
|
private string $mobile;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $firstName;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $lastName;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 180, nullable: true, unique: true)]
|
||||||
|
private ?string $email;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
private string $status = 'pending'; // pending, active, blocked
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $roles = ['ROLE_USER'];
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# پایگاه داده — تسک ۰۲: ماژول احراز هویت
|
||||||
|
|
||||||
|
## جدول: users
|
||||||
|
_(از بخش ۲.۱ مستند + بررسی DB backup Drupal)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT AUTO_INCREMENT PK | id | شناسه داخلی |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | شناسه عمومی UUID |
|
||||||
|
| uid | INT FK → users.id NOT NULL | uid | ارجاع به خود جدول (self-reference — در Drupal الزامی) |
|
||||||
|
| mobile_number | VARCHAR(20) UNIQUE NOT NULL | name | شماره موبایل — به عنوان username استفاده میشود |
|
||||||
|
| password | VARCHAR(255) NOT NULL | pass | رمز عبور هششده با bcrypt |
|
||||||
|
| realname | VARCHAR(255) NULL | field_realname | نام و نامخانوادگی کامل (نه first_name/last_name!) |
|
||||||
|
| picture | VARCHAR(500) NULL | user_picture | آدرس تصویر پروفایل |
|
||||||
|
| email | VARCHAR(180) UNIQUE NULL | mail | ایمیل (اختیاری) |
|
||||||
|
| status | TINYINT(1) DEFAULT 1 | status | ۱=فعال، ۰=غیرفعال |
|
||||||
|
| roles | JSON NOT NULL | — | نقشها — مثال: `{"0":"authenticated","2":"doctor"}` |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp — زمان ایجاد |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp — آخرین ویرایش |
|
||||||
|
|
||||||
|
> **⚠ مهم:**
|
||||||
|
> - Drupal از `realname` (یک فیلد) استفاده میکند، **نه** `first_name` + `last_name`!
|
||||||
|
> - timestampها نوع **INT** هستند (Unix timestamp)، نه DATETIME
|
||||||
|
> - `status` نوع **TINYINT** است (نه ENUM)
|
||||||
|
> - `uid` self-reference است — در Drupal هر user به خودش اشاره میکند
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_users_uuid ON users(uuid);
|
||||||
|
CREATE UNIQUE INDEX idx_users_mobile ON users(mobile_number);
|
||||||
|
CREATE UNIQUE INDEX idx_users_email ON users(email);
|
||||||
|
CREATE INDEX idx_users_status ON users(status);
|
||||||
|
```
|
||||||
|
|
||||||
|
## ذخیرهسازی OTP در Redis (نه پایگاه داده)
|
||||||
|
```
|
||||||
|
کلید: otp:{uuid} ← UUID از /api/v1/user/send-code برگردانده میشود (نه mobile!)
|
||||||
|
مقدار: {"code": "12345", "attempts": 0}
|
||||||
|
TTL: 1200 ثانیه (20 دقیقه)
|
||||||
|
```
|
||||||
|
|
||||||
|
## جریان OTP (MobileGrant)
|
||||||
|
```
|
||||||
|
1. POST /api/v1/user/send-code → {mobile, captcha_token}
|
||||||
|
→ UUID تولید + کد OTP ذخیره در Redis با کلید otp:{uuid}
|
||||||
|
→ UUID برگردانده میشود
|
||||||
|
|
||||||
|
2. POST /api/v1/user/verify-code → {mobile, captcha_token}
|
||||||
|
→ کد از Redis با کلید otp:{uuid} تأیید میشود
|
||||||
|
|
||||||
|
3. POST /oauth/token → grant_type=mobile (MobileGrant)
|
||||||
|
→ JWT token صادر میشود
|
||||||
|
|
||||||
|
4. GET /oauth/userinfo → Bearer token
|
||||||
|
→ اطلاعات کاربر + شیء clinic_pro برگردانده میشود
|
||||||
|
```
|
||||||
|
|
||||||
|
## نمونه پاسخ GET /oauth/userinfo (واقعی از Drupal)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": null,
|
||||||
|
"email_verified": true,
|
||||||
|
"username": "09120671710",
|
||||||
|
"id": "22",
|
||||||
|
"uuid": "d200f5c5-d717-4526-b263-d3bb7d0228d6",
|
||||||
|
"created": "1762262151",
|
||||||
|
"changed": "1762262267",
|
||||||
|
"status": "1",
|
||||||
|
"roles": {
|
||||||
|
"0": "authenticated",
|
||||||
|
"2": "doctor"
|
||||||
|
},
|
||||||
|
"realName": "single doctor",
|
||||||
|
"picture": [],
|
||||||
|
"clinic_pro": {
|
||||||
|
"base_role": "doctor",
|
||||||
|
"db_uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||||
|
"db_key": "22bea8c1dc64d9b0c744810722519efe7290276ecd82d9bc650482aa4539bf0d",
|
||||||
|
"my_doctors_uuid": {
|
||||||
|
"uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||||
|
"id": "29",
|
||||||
|
"name": "single doctor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **نکات `/oauth/userinfo`:**
|
||||||
|
> - `realName` با حرف بزرگ N (camelCase)
|
||||||
|
> - `roles` یک object است، نه array: `{"0":"authenticated","2":"doctor"}`
|
||||||
|
> - `clinic_pro.base_role` → نقش اصلی: `doctor`, `clinic`, `doctor_s_secretary`
|
||||||
|
> - `clinic_pro.db_uuid` → UUID موجودیت doctor/clinic در جدول clinic_pro
|
||||||
|
> - فقط نقشهای `doctor`, `clinic`, `doctor_s_secretary` میتوانند با پسورد لاگین کنند
|
||||||
|
|
||||||
|
## روابط با سایر جداول
|
||||||
|
```
|
||||||
|
users → user_profiles (OneToOne) : تسک ۰۳
|
||||||
|
users → doctors (OneToOne) : تسک ۰۵
|
||||||
|
users → clinics (OneToOne) : تسک ۰۶
|
||||||
|
users → appointments (OneToMany) : تسک ۱۰
|
||||||
|
users → payments (OneToMany) : تسک ۱۵
|
||||||
|
users → representations (OneToOne): تسک ۱۶
|
||||||
|
```
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۰۲: ماژول احراز هویت
|
||||||
|
|
||||||
|
## مهمترین تفاوت با طراحی اولیه
|
||||||
|
|
||||||
|
### جریان OTP با UUID (نه موبایل)
|
||||||
|
در Drupal، کد OTP **با UUID** ذخیره میشود، نه با شماره موبایل:
|
||||||
|
```php
|
||||||
|
// ساختار ذخیرهسازی در KeyValue/Redis:
|
||||||
|
key = uuid (تولیدشده در send-code)
|
||||||
|
value = { "code": "12345", "mobile": "09120671713" }
|
||||||
|
TTL = 1200 ثانیه
|
||||||
|
```
|
||||||
|
|
||||||
|
`verify-code` و `oauth/token` هر دو `uuid` میخواهند، نه موبایل.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// OtpService.php
|
||||||
|
public function generate(string $mobile): array
|
||||||
|
{
|
||||||
|
$uuid = Uuid::uuid4()->toString();
|
||||||
|
$code = $this->isDev() ? '12345' : (string) random_int(10000, 99999);
|
||||||
|
$this->redis->setex("otp:{$uuid}", 1200, json_encode([
|
||||||
|
'code' => $code,
|
||||||
|
'mobile' => $mobile,
|
||||||
|
]));
|
||||||
|
return ['uuid' => $uuid, 'code' => $code];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $uuid, string $code): bool
|
||||||
|
{
|
||||||
|
$data = json_decode($this->redis->get("otp:{$uuid}"), true);
|
||||||
|
if (!$data || $data['code'] !== $code) return false;
|
||||||
|
$this->redis->del("otp:{$uuid}");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMobileByUuid(string $uuid): ?string
|
||||||
|
{
|
||||||
|
$data = json_decode($this->redis->get("otp:{$uuid}"), true);
|
||||||
|
return $data['mobile'] ?? null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## MobileGrant در Symfony
|
||||||
|
به جای OAuth2 کامل، یک custom JWT grant پیادهسازی کن:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /oauth/token
|
||||||
|
grant_type=mobile
|
||||||
|
uuid=...
|
||||||
|
code=...
|
||||||
|
client_id=clinic-pro
|
||||||
|
client_secret=...
|
||||||
|
|
||||||
|
→ OtpService::verify(uuid, code) تأیید کند
|
||||||
|
→ getMobileByUuid(uuid) موبایل را بگیر
|
||||||
|
→ کاربر را پیدا یا بساز
|
||||||
|
→ JWT صادر کن
|
||||||
|
```
|
||||||
|
|
||||||
|
## SMS Providers
|
||||||
|
دو provider واقعی در Drupal:
|
||||||
|
|
||||||
|
**KavehNegar:**
|
||||||
|
```php
|
||||||
|
POST https://api.kavenegar.com/v1/{apiKey}/sms/send.json
|
||||||
|
form: receptor={mobile}&message={code}&sender=10004346
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rangineh:**
|
||||||
|
از پیادهسازی در `sms_provider/src/Plugin/SmsProvider/Rangineh.php` الگو بگیر.
|
||||||
|
|
||||||
|
**Interface در Symfony:**
|
||||||
|
```php
|
||||||
|
interface SmsProviderInterface {
|
||||||
|
public function send(string $mobile, string $message): bool;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
پیکربندی در `.env`:
|
||||||
|
```
|
||||||
|
SMS_PROVIDER=kavenegar # kavenegar | rangineh | null (dev)
|
||||||
|
KAVENEGAR_API_KEY=...
|
||||||
|
KAVENEGAR_SENDER=10004346
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting (مقادیر واقعی)
|
||||||
|
```php
|
||||||
|
// IP: 50 درخواست در ساعت
|
||||||
|
// Mobile: 30 درخواست در ساعت
|
||||||
|
// keys Redis:
|
||||||
|
// rate_ip:{ip} TTL=3600
|
||||||
|
// rate_mobile:{mobile} TTL=3600
|
||||||
|
```
|
||||||
|
|
||||||
|
## TTL کد OTP: 1200 ثانیه (20 دقیقه)
|
||||||
|
در طراحی اولیه اشتباهاً 120 ثانیه نوشته شده بود — مقدار واقعی از Drupal ۱۲۰۰ است.
|
||||||
|
|
||||||
|
## Flood Control (از Drupal)
|
||||||
|
علاوه بر rate limiting، Drupal از flood control نیز استفاده میکند:
|
||||||
|
- `oauth2_grant.mobile.failed_login_ip` — IP based
|
||||||
|
- `oauth2_grant.mobile.failed_login_user` — User based
|
||||||
|
در Symfony از Symfony's `RateLimiter` component جایگزین کن.
|
||||||
|
|
||||||
|
## سازگاری با کلاینت: GET /session/token
|
||||||
|
```php
|
||||||
|
return new Response(bin2hex(random_bytes(22)), 200, ['Content-Type' => 'text/plain']);
|
||||||
|
```
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
DELETE /api/v1/user/{id} → ROLE_ADMIN
|
||||||
|
PATCH /api/v1/user/{id} → owner یا ROLE_ADMIN
|
||||||
|
```
|
||||||
|
|
||||||
|
## ساختار واقعی GET /oauth/userinfo (از سرور Drupal)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": null,
|
||||||
|
"email_verified": true,
|
||||||
|
"username": "09120671710",
|
||||||
|
"id": "22",
|
||||||
|
"uuid": "d200f5c5-d717-4526-b263-d3bb7d0228d6",
|
||||||
|
"created": "1762262151",
|
||||||
|
"changed": "1762262267",
|
||||||
|
"status": "1",
|
||||||
|
"roles": {
|
||||||
|
"0": "authenticated",
|
||||||
|
"2": "doctor"
|
||||||
|
},
|
||||||
|
"realName": "single doctor",
|
||||||
|
"picture": [],
|
||||||
|
"clinic_pro": {
|
||||||
|
"base_role": "doctor",
|
||||||
|
"db_uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||||
|
"db_key": "22bea8c1dc64d9b0c744810722519efe7290276ecd82d9bc650482aa4539bf0d",
|
||||||
|
"my_doctors_uuid": {
|
||||||
|
"uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||||
|
"id": "29",
|
||||||
|
"name": "single doctor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### نکات مهم userinfo:
|
||||||
|
- `roles` یک **object** (نه array) با کلیدهای عددی است: `{"0": "authenticated", "2": "doctor"}`
|
||||||
|
- `id` و `uuid` از users table
|
||||||
|
- `realName` با R بزرگ (camelCase)
|
||||||
|
- `clinic_pro.base_role` = نقش اصلی کاربر
|
||||||
|
- `clinic_pro.db_uuid` = UUID موجودیت مرتبط (doctor/clinic/representation)
|
||||||
|
- `clinic_pro.db_key` = token دسترسی برای عملیات داخلی
|
||||||
|
- `clinic_pro.my_doctors_uuid` (فقط برای doctor) = مشخصات پروفایل دکتر
|
||||||
|
|
||||||
|
## نقشهای مجاز برای login با password
|
||||||
|
```
|
||||||
|
فقط این نقشها میتوانند با POST /oauth/token (grant_type=password) لاگین کنند:
|
||||||
|
- doctor
|
||||||
|
- clinic
|
||||||
|
- doctor_s_secretary
|
||||||
|
```
|
||||||
|
بقیه (patient, representation, admin) فقط از طریق OTP لاگین میکنند.
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
# تسک ۰۲: ماژول احراز هویت
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
دو روش ورود پشتیبانی میشود:
|
||||||
|
|
||||||
|
**روش اول — OTP موبایل (برای بیماران و عموم):**
|
||||||
|
`send-code` → UUID برمیگرداند → `verify-code` با UUID+code → `oauth/token` صادر میکند JWT + Refresh Token
|
||||||
|
|
||||||
|
**روش دوم — Username/Password (فقط برای دکتر، کلینیک، منشی):**
|
||||||
|
`POST /api/v1/user/login` → مستقیم JWT + Refresh Token صادر میکند
|
||||||
|
|
||||||
|
## Endpoint ها
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/user/send-code` | ارسال OTP، بازگشت `uuid` | خیر |
|
||||||
|
| POST | `/api/v1/user/verify-code` | تأیید OTP با `uuid` + `code` | خیر |
|
||||||
|
| POST | `/api/v1/user/register` | تکمیل ثبتنام | خیر |
|
||||||
|
| POST | `/api/v1/user/login` | لاگین با username+password (دکتر/کلینیک/منشی) | خیر |
|
||||||
|
| GET | `/session/token` | CSRF Token (سازگاری با کلاینت) | خیر |
|
||||||
|
| POST | `/oauth/token` | صدور JWT + Refresh Token (OTP flow) | خیر |
|
||||||
|
| POST | `/oauth/token/refresh` | تجدید JWT با Refresh Token | خیر |
|
||||||
|
| GET | `/oauth/userinfo` | اطلاعات کاربر لاگینشده | بله |
|
||||||
|
| POST | `/oauth/logout` | لغو توکنها | بله |
|
||||||
|
| DELETE | `/api/v1/user/{id}` | حذف کاربر | بله (Admin) |
|
||||||
|
| PATCH | `/api/v1/user/{id}` | ویرایش اطلاعات پایه کاربر | بله (Owner) |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱ کامل شده باشد
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۱۲ تا ۱۴ ساعت
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## جریان واقعی OTP
|
||||||
|
|
||||||
|
### مرحله ۱ — POST /api/v1/user/send-code
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "mobile": "09120671713", "captcha_token": "" }
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"uuid": "a1b2c3d4-e5f6-...",
|
||||||
|
"message": "کد تایید با موفقیت ارسال شد."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 429 (rate limit)
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"errors": [{ "code": "ERR_AUTH_004", "message": "تعداد تلاشها به حد مجاز رسیده است" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**منطق داخلی:**
|
||||||
|
```
|
||||||
|
1. بررسی rate limit (IP: 50/hr، mobile: 30/hr)
|
||||||
|
2. بررسی تعداد تلاشهای OTP برای این mobile (حداکثر 5 بار در TTL)
|
||||||
|
3. تولید کد 5 رقمی
|
||||||
|
4. ذخیره در Redis: key=otp:{uuid}، value={mobile, code, attempts:0}، TTL=1200s
|
||||||
|
5. ارسال SMS async (از طریق Symfony Messenger)
|
||||||
|
6. بازگشت uuid
|
||||||
|
```
|
||||||
|
|
||||||
|
### مرحله ۲ — POST /api/v1/user/verify-code
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "uuid": "a1b2c3d4-...", "code": "12345" }
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{ "message": "کد با موفقیت تایید شد.", "success": true }
|
||||||
|
|
||||||
|
// Response 400 — کد اشتباه
|
||||||
|
{ "success": false, "errors": [{ "code": "ERR_AUTH_002", "message": "کد OTP نامعتبر است" }] }
|
||||||
|
|
||||||
|
// Response 400 — کد منقضی
|
||||||
|
{ "success": false, "errors": [{ "code": "ERR_AUTH_003", "message": "کد OTP منقضی شده است" }] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**منطق داخلی:**
|
||||||
|
```
|
||||||
|
1. بررسی وجود key در Redis
|
||||||
|
2. مقایسه code با hash_equals() — نه == (جلوگیری از Timing Attack)
|
||||||
|
3. افزایش attempts در Redis
|
||||||
|
4. اگر attempts > 5 → خطای ERR_AUTH_004 و حذف key
|
||||||
|
5. در صورت صحت → افزودن verified:true به Redis
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ⚠ استفاده از hash_equals برای جلوگیری از Timing Attack
|
||||||
|
if (!hash_equals($storedCode, $submittedCode)) {
|
||||||
|
// کد اشتباه
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### مرحله ۳ — POST /oauth/token (MobileGrant)
|
||||||
|
```
|
||||||
|
// Request (form-data)
|
||||||
|
grant_type=mobile
|
||||||
|
client_id=clinic-pro
|
||||||
|
client_secret=secret
|
||||||
|
uuid=a1b2c3d4-...
|
||||||
|
code=12345
|
||||||
|
registration=true
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"refresh_token": "a8f3b2...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"refresh_token_expires_in": 2592000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**منطق داخلی:**
|
||||||
|
```
|
||||||
|
1. خواندن uuid از Redis — بررسی verified:true
|
||||||
|
2. اگر کاربر جدید و registration=true → ایجاد کاربر
|
||||||
|
3. صدور JWT (TTL=3600s)
|
||||||
|
4. تولید Refresh Token (random_bytes(32) → bin2hex → 64 char)
|
||||||
|
5. هش کردن Refresh Token: hash('sha256', $rawToken)
|
||||||
|
6. ذخیره در Redis: key=refresh:{hash}، value=user_id، TTL=2592000s
|
||||||
|
7. حذف OTP از Redis
|
||||||
|
8. بازگشت access_token + raw refresh_token (نه hash)
|
||||||
|
```
|
||||||
|
|
||||||
|
```php
|
||||||
|
// تولید و ذخیره Refresh Token
|
||||||
|
$rawToken = bin2hex(random_bytes(32)); // 64 کاراکتر hex
|
||||||
|
$hashedToken = hash('sha256', $rawToken); // ذخیره hash در Redis
|
||||||
|
$redis->setex("refresh:{$hashedToken}", 2592000, $userId);
|
||||||
|
// ارسال rawToken به کلاینت — هرگز hash را ارسال نکن
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Refresh Token
|
||||||
|
|
||||||
|
### POST /oauth/token/refresh
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"refresh_token": "a8f3b2c1d0..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"refresh_token": "new_token_here",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 401 — Refresh Token نامعتبر یا منقضی
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"errors": [{ "code": "ERR_AUTH_001", "message": "Refresh Token نامعتبر یا منقضی شده است" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**منطق:**
|
||||||
|
```
|
||||||
|
1. هش کردن token دریافتی: hash('sha256', $submittedToken)
|
||||||
|
2. جستجو key=refresh:{hash} در Redis
|
||||||
|
3. اگر وجود ندارد → 401
|
||||||
|
4. صدور JWT جدید
|
||||||
|
5. Refresh Token Rotation:
|
||||||
|
- حذف hash قدیمی از Redis
|
||||||
|
- تولید rawToken جدید + hash جدید
|
||||||
|
- ذخیره hash جدید با TTL=2592000s
|
||||||
|
6. بازگشت access_token + rawToken جدید
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /oauth/logout
|
||||||
|
```json
|
||||||
|
// Request — Header: Authorization: Bearer {access_token}
|
||||||
|
// Body:
|
||||||
|
{ "refresh_token": "a8f3b2c1d0..." }
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{ "success": true, "message": "خروج با موفقیت انجام شد" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**منطق:**
|
||||||
|
```
|
||||||
|
1. حذف refresh:{token} از Redis
|
||||||
|
2. افزودن JWT به blacklist: key=blacklist:{jti}، TTL=remaining_ttl
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /session/token — CSRF
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Response 200
|
||||||
|
{ "token": "XwZ9k2P..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
ذخیره در Redis: `key=csrf:{token}` با TTL=3600s
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /oauth/userinfo
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 33,
|
||||||
|
"uuid": "...",
|
||||||
|
"mobile_number": "09120671713",
|
||||||
|
"realName": "علی احمدی",
|
||||||
|
"picture": null,
|
||||||
|
"status": 1,
|
||||||
|
"roles": { "0": "authenticated", "2": "doctor" },
|
||||||
|
"clinic_pro": {
|
||||||
|
"base_role": "doctor",
|
||||||
|
"db_uuid": "...",
|
||||||
|
"db_key": 29,
|
||||||
|
"my_doctors_uuid": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
| نوع | حد | پنجره |
|
||||||
|
|-----|-----|-------|
|
||||||
|
| IP | 50 درخواست | ساعتی |
|
||||||
|
| Mobile | 30 درخواست | ساعتی |
|
||||||
|
| OTP Attempts | 5 تلاش | در طول TTL (1200s) |
|
||||||
|
|
||||||
|
**Rate Limit Headers در Response:**
|
||||||
|
```
|
||||||
|
X-RateLimit-Limit: 50
|
||||||
|
X-RateLimit-Remaining: 47
|
||||||
|
X-RateLimit-Reset: 1748003600
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SMS Providers
|
||||||
|
|
||||||
|
```
|
||||||
|
اصلی: KavehNegar (KAVENEGAR_API_KEY)
|
||||||
|
جایگزین: Rangineh (RANGINEH_API_KEY)
|
||||||
|
Fallback logic: اگر KavehNegar خطا داد → Rangineh
|
||||||
|
محیط dev: کد ثابت 12345 (بدون ارسال واقعی)
|
||||||
|
```
|
||||||
|
|
||||||
|
ارسال SMS از طریق **Symfony Messenger** (async) انجام میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redis Key Schema
|
||||||
|
|
||||||
|
| Key | Value | TTL |
|
||||||
|
|-----|-------|-----|
|
||||||
|
| `otp:{uuid}` | `{mobile, code, attempts, verified}` | 1200s |
|
||||||
|
| `refresh:{hash}` | `user_id` | 2592000s |
|
||||||
|
| `blacklist:{jti}` | `1` | remaining JWT TTL |
|
||||||
|
| `csrf:{token}` | `1` | 3600s |
|
||||||
|
| `rate_ip:{ip}` | count | 3600s |
|
||||||
|
| `rate_mobile:{mobile}` | count | 3600s |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## لاگین با Username/Password (برای دکتر، کلینیک، منشی)
|
||||||
|
|
||||||
|
### POST /api/v1/user/login
|
||||||
|
|
||||||
|
**چه کسانی میتوانند استفاده کنند:**
|
||||||
|
- کاربران با نقش `doctor`
|
||||||
|
- کاربران با نقش `clinic`
|
||||||
|
- کاربران با نقش `doctor_s_secretary`
|
||||||
|
|
||||||
|
بیماران عادی فقط از طریق OTP وارد میشوند — این endpoint برای آنها در دسترس نیست.
|
||||||
|
|
||||||
|
> **⚠ نام کاربری برای همه کاربران و همه نقشها = شماره موبایل است.**
|
||||||
|
> ایمیل به عنوان username پشتیبانی نمیشود.
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"mobile_number": "09120671713",
|
||||||
|
"password": "SecurePass123!"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"refresh_token": "a8f3b2c1d0...",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"refresh_token_expires_in": 2592000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 401 — اطلاعات اشتباه
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"errors": [{ "code": "ERR_AUTH_005", "message": "نام کاربری یا رمز عبور اشتباه است" }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 403 — نقش کاربر اجازه ندارد از این روش استفاده کند
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"errors": [{ "code": "ERR_AUTH_006", "message": "این نوع حساب فقط از طریق کد OTP وارد میشود" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**منطق داخلی:**
|
||||||
|
```
|
||||||
|
1. جستجوی کاربر با mobile_number
|
||||||
|
2. بررسی وجود کاربر و password_hash
|
||||||
|
3. تأیید رمز با password_verify()
|
||||||
|
4. بررسی نقش کاربر — فقط doctor / clinic / doctor_s_secretary مجاز
|
||||||
|
5. صدور JWT (TTL=3600s)
|
||||||
|
6. تولید Refresh Token و ذخیره SHA-256 hash در Redis
|
||||||
|
7. ثبت رویداد login_success در security_logs
|
||||||
|
8. بازگشت access_token + raw refresh_token
|
||||||
|
```
|
||||||
|
|
||||||
|
**پیادهسازی در Symfony — Custom Authenticator:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Auth/Security/PasswordAuthenticator.php
|
||||||
|
class PasswordAuthenticator extends AbstractAuthenticator
|
||||||
|
{
|
||||||
|
public function supports(Request $request): ?bool
|
||||||
|
{
|
||||||
|
return $request->getPathInfo() === '/api/v1/user/login'
|
||||||
|
&& $request->isMethod('POST');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(Request $request): Passport
|
||||||
|
{
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
$mobile = $data['mobile_number'] ?? '';
|
||||||
|
$password = $data['password'] ?? '';
|
||||||
|
|
||||||
|
return new Passport(
|
||||||
|
new UserBadge($mobile, fn($m) => $this->userRepo->findByMobile($m)),
|
||||||
|
new PasswordCredentials($password),
|
||||||
|
[new CsrfTokenBadge('login', $data['_csrf'] ?? '')]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||||
|
{
|
||||||
|
$user = $token->getUser();
|
||||||
|
|
||||||
|
// بررسی نقش — فقط doctor/clinic/secretary
|
||||||
|
$allowedRoles = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY'];
|
||||||
|
if (empty(array_intersect($user->getRoles(), $allowedRoles))) {
|
||||||
|
return new JsonResponse([
|
||||||
|
'success' => false,
|
||||||
|
'errors' => [['code' => 'ERR_AUTH_006', 'message' => 'این نوع حساب فقط از طریق کد OTP وارد میشود']]
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$accessToken = $this->jwtManager->create($user);
|
||||||
|
$rawToken = bin2hex(random_bytes(32));
|
||||||
|
$hashedToken = hash('sha256', $rawToken);
|
||||||
|
$this->redis->setex("refresh:{$hashedToken}", 2592000, $user->getId());
|
||||||
|
|
||||||
|
$this->auditLog->log('login_success', $user->getId(), $request, ['method' => 'password']);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
'refresh_token' => $rawToken,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'expires_in' => 3600,
|
||||||
|
'refresh_token_expires_in' => 2592000,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
|
||||||
|
{
|
||||||
|
$this->auditLog->log('login_failed', null, $request, ['reason' => $exception->getMessage()]);
|
||||||
|
|
||||||
|
return new JsonResponse([
|
||||||
|
'success' => false,
|
||||||
|
'errors' => [['code' => 'ERR_AUTH_005', 'message' => 'نام کاربری یا رمز عبور اشتباه است']]
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**فیلد password در جدول users:**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE users ADD COLUMN password_hash VARCHAR(255) NULL;
|
||||||
|
-- NULL برای بیمارانی که فقط OTP دارند
|
||||||
|
-- پر شده برای doctor/clinic/secretary
|
||||||
|
```
|
||||||
|
|
||||||
|
**تغییر رمز عبور (دکتر/کلینیک/منشی):**
|
||||||
|
```json
|
||||||
|
PATCH /api/v1/user/{uuid}/password
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"current_password": "OldPass123!",
|
||||||
|
"new_password": "NewPass456!",
|
||||||
|
"new_password_confirmation": "NewPass456!"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{ "success": true, "message": "رمز عبور با موفقیت تغییر کرد" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**قوانین رمز عبور:**
|
||||||
|
- حداقل ۸ کاراکتر
|
||||||
|
- حداقل یک حرف بزرگ
|
||||||
|
- حداقل یک عدد
|
||||||
|
- bcrypt با cost=12
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠ استاندارد JWT در Symfony — کجا توکن را ارسال کنیم؟
|
||||||
|
|
||||||
|
این یکی از مهمترین نکات امنیتی پروژه است.
|
||||||
|
|
||||||
|
### Access Token — همیشه در هدر Authorization
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
|
||||||
|
```
|
||||||
|
|
||||||
|
**LexikJWTAuthenticationBundle** این هدر را در firewall `api` به صورت خودکار بررسی میکند.
|
||||||
|
هیچ کد اضافهای در Controller لازم نیست — middleware JWT مجاز بودن را تأیید میکند.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config/packages/security.yaml
|
||||||
|
firewalls:
|
||||||
|
api:
|
||||||
|
pattern: ^/(api|oauth)/
|
||||||
|
stateless: true
|
||||||
|
jwt: ~ # ← این خط کافی است؛ خودش هدر Authorization را میخواند
|
||||||
|
```
|
||||||
|
|
||||||
|
**هرگز access_token را اینجا نفرست:**
|
||||||
|
```
|
||||||
|
❌ GET /api/v1/doctor?token=eyJ... ← URL param
|
||||||
|
❌ POST /api/v1/payment body: {token: ...} ← Request body
|
||||||
|
❌ Cookie: access_token=eyJ... ← Cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
### Refresh Token — فقط در body برای endpoint مخصوص
|
||||||
|
|
||||||
|
Refresh Token هیچوقت در `Authorization` header نمیرود. فقط یکبار و فقط به endpoint `/oauth/token/refresh` در body ارسال میشود:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /oauth/token/refresh
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"refresh_token": "a8f3b2c1d0e4f5..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
این endpoint در security.yaml به صورت `PUBLIC_ACCESS` است چون تأیید هویت با خود Refresh Token انجام میشود (نه با JWT).
|
||||||
|
|
||||||
|
### خلاصه گردش توکنها
|
||||||
|
|
||||||
|
```
|
||||||
|
[ورود] → Response body:
|
||||||
|
{
|
||||||
|
"access_token": "eyJ..." ← ذخیره در memory (نه localStorage)
|
||||||
|
"refresh_token": "a8f3b2..." ← ذخیره در HttpOnly Cookie یا secure storage
|
||||||
|
}
|
||||||
|
|
||||||
|
[هر درخواست محافظتشده]:
|
||||||
|
Authorization: Bearer eyJ... ← فقط access_token در header
|
||||||
|
|
||||||
|
[وقتی access_token منقضی شد]:
|
||||||
|
POST /oauth/token/refresh
|
||||||
|
body: { "refresh_token": "a8f3b2..." }
|
||||||
|
→ Response: { "access_token": "eyJ_new...", "refresh_token": "new_refresh..." }
|
||||||
|
|
||||||
|
[خروج]:
|
||||||
|
POST /oauth/logout
|
||||||
|
Authorization: Bearer eyJ...
|
||||||
|
body: { "refresh_token": "a8f3b2..." }
|
||||||
|
→ هر دو توکن باطل میشوند
|
||||||
|
```
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# جریان کاربری — تسک ۰۲: ماژول احراز هویت
|
||||||
|
|
||||||
|
## جریان کامل ورود با OTP (جریان واقعی از Drupal)
|
||||||
|
|
||||||
|
```
|
||||||
|
کاربر موبایل را وارد میکند
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/v1/user/send-code
|
||||||
|
{ mobile: "09120671713", captcha_token: "" }
|
||||||
|
│
|
||||||
|
├─► اعتبارسنجی فرمت موبایل (/^(\+98|0)?9\d{9}$/)
|
||||||
|
├─► بررسی rate limit IP (max 50/hour)
|
||||||
|
├─► بررسی rate limit Mobile (max 30/hour)
|
||||||
|
├─► تولید UUID + کد OTP
|
||||||
|
├─► ذخیره در Redis: otp:{uuid} = {code, mobile} (TTL=1200s)
|
||||||
|
└─► ارسال SMS
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Response: { uuid: "a1b2c3d4-...", message: "..." }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
کاربر کد را وارد میکند
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/v1/user/verify-code
|
||||||
|
{ uuid: "a1b2c3d4-...", code: "12345" }
|
||||||
|
│
|
||||||
|
├─► بررسی وجود uuid در Redis
|
||||||
|
├─► مقایسه code
|
||||||
|
│ ├─► نادرست: خطا
|
||||||
|
│ └─► درست: حذف از Redis
|
||||||
|
└─► Response: { message: "کد با موفقیت تایید شد.", success: true }
|
||||||
|
|
||||||
|
⚠️ verify-code در Drupal JWT صادر نمیکند!
|
||||||
|
JWT در مرحله بعد با /oauth/token صادر میشود.
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /oauth/token (MobileGrant)
|
||||||
|
grant_type=mobile
|
||||||
|
uuid=a1b2c3d4-... ← همان uuid
|
||||||
|
code=12345 ← همان code
|
||||||
|
client_id=clinic-pro
|
||||||
|
client_secret=...
|
||||||
|
registration=true ← اگر false باشد، فقط کاربر موجود میتواند وارد شود
|
||||||
|
│
|
||||||
|
├─► OtpService::verify(uuid, code)
|
||||||
|
├─► OtpService::getMobileByUuid(uuid)
|
||||||
|
├─► بررسی وجود کاربر با این موبایل
|
||||||
|
│ ├─► وجود دارد: ادامه
|
||||||
|
│ └─► جدید + registration=true: ایجاد user با status=pending
|
||||||
|
└─► صدور JWT
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Response: { access_token, refresh_token, token_type: "Bearer", expires_in: 3600 }
|
||||||
|
```
|
||||||
|
|
||||||
|
## جریان تمدید Token
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /oauth/token
|
||||||
|
grant_type=refresh_token
|
||||||
|
client_id=clinic-pro
|
||||||
|
client_secret=...
|
||||||
|
refresh_token=eyJ...
|
||||||
|
│
|
||||||
|
└─► بررسی refresh_token → صدور access_token جدید
|
||||||
|
```
|
||||||
|
|
||||||
|
## جریان دریافت اطلاعات کاربر
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /oauth/userinfo
|
||||||
|
Authorization: Bearer {access_token}
|
||||||
|
│
|
||||||
|
└─► decode JWT → بازگشت: sub, uuid, name, email, phone_number, scope
|
||||||
|
```
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# معماری — تسک ۰۳: ماژول پروفایل کاربر
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/UserProfile/
|
||||||
|
├── Controller/
|
||||||
|
│ └── UserProfileController.php
|
||||||
|
├── Service/
|
||||||
|
│ └── UserProfileService.php
|
||||||
|
├── Repository/
|
||||||
|
│ └── UserProfileRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ └── UserProfile.php
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ ├── CreateUserProfileRequest.php
|
||||||
|
│ │ └── UpdateUserProfileRequest.php
|
||||||
|
│ └── Response/
|
||||||
|
│ └── UserProfileResponse.php
|
||||||
|
└── Voter/
|
||||||
|
└── UserProfileVoter.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: UserProfile
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'user_profiles')]
|
||||||
|
class UserProfile
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\OneToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 200, nullable: true)]
|
||||||
|
private ?string $name;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $description;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
|
private ?string $birthday;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $basicInsurance;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 30, nullable: true)]
|
||||||
|
private ?string $bloodType;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
|
private ?string $education;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $fathersName;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 10, nullable: true)]
|
||||||
|
private ?string $gender;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
|
private ?string $homePhone;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $job;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 30, nullable: true)]
|
||||||
|
private ?string $maritalStatus;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
|
private ?string $supplementaryInsurance;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
|
private ?string $workPhone;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $address;
|
||||||
|
|
||||||
|
// اطلاعات پزشکی پیچیده به صورت JSON
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $diseases;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $allergies;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $medications;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $surgeries;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $familyHistory;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $relatives;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# پایگاه داده — تسک ۰۳: ماژول پروفایل کاربر
|
||||||
|
|
||||||
|
## جدول: profiles
|
||||||
|
_(entity_type=profile — از DB backup و config تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id UNIQUE NOT NULL | uid | کاربر مرتبط |
|
||||||
|
| label | VARCHAR(255) NULL | label | نام نمایشی پروفایل |
|
||||||
|
| family | VARCHAR(25) NULL | field_family | نام خانوادگی (max 25) |
|
||||||
|
| fathers_name | VARCHAR(255) NULL | field_fathers_name | نام پدر |
|
||||||
|
| national_code | VARCHAR(10) NULL | field_national_code | کد ملی (max 10) |
|
||||||
|
| national_code_approved | TINYINT(1) DEFAULT 0 | field_national_code_approved | کد ملی تأیید شده |
|
||||||
|
| gender | VARCHAR(10) NULL | field_gender | `male` یا `female` |
|
||||||
|
| date_of_birth | INT NULL | field_date_of_birth | تاریخ تولد (Unix timestamp) |
|
||||||
|
| blood_type | VARCHAR(20) NULL | field_blood_type | گروه خونی |
|
||||||
|
| marital_status | VARCHAR(30) NULL | field_marital_status | وضعیت تأهل |
|
||||||
|
| education | VARCHAR(100) NULL | field_education | تحصیلات |
|
||||||
|
| job | VARCHAR(100) NULL | field_job | شغل |
|
||||||
|
| address | LONGTEXT NULL | field_address | آدرس |
|
||||||
|
| home_phone | VARCHAR(30) NULL | field_home_phone | تلفن منزل |
|
||||||
|
| work_phone | VARCHAR(30) NULL | field_work_phone | تلفن کار |
|
||||||
|
| insurance_id | VARCHAR(50) NULL | field_insurance_id | شماره بیمه |
|
||||||
|
| basic_insurance_id | INT FK → categories.id NULL | field_basic_insurance | entity ref → category (بیمه پایه) |
|
||||||
|
| supplementary_insurance_id | INT FK → categories.id NULL | field_supplementary_insurance | entity ref → category (بیمه تکمیلی) |
|
||||||
|
| other | LONGTEXT NULL | field_other | سایر اطلاعات |
|
||||||
|
| sharing_with_user | TINYINT(1) DEFAULT 0 | field_sharing_with_user | اشتراک با کاربر دیگر |
|
||||||
|
| description | LONGTEXT NULL | description | توضیحات (base field) |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## مقادیر field_gender (profile — از config)
|
||||||
|
```
|
||||||
|
male → آقا
|
||||||
|
female → خانم
|
||||||
|
```
|
||||||
|
|
||||||
|
## مقادیر field_blood_type (از config)
|
||||||
|
```
|
||||||
|
a_positive → A+ a_negative → A-
|
||||||
|
b_positive → B+ b_negative → B-
|
||||||
|
o_positive → O+ o_negative → O-
|
||||||
|
ab_positive → AB+ ab_negative → AB-
|
||||||
|
```
|
||||||
|
|
||||||
|
## مقادیر field_education (از Manual)
|
||||||
|
```
|
||||||
|
diploma → دیپلم
|
||||||
|
postgraduate_diploma → فوق دیپلم
|
||||||
|
bachelor_s_degree → لیسانس
|
||||||
|
master_s_degree → فوق لیسانس
|
||||||
|
doctorate → دکترا
|
||||||
|
```
|
||||||
|
|
||||||
|
## ساختار field_other (JSON — از Manual و نمونه Request واقعی)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"disease": [
|
||||||
|
{ "id": 1, "name": "فشار خون", "status": "false" },
|
||||||
|
{ "id": 2, "name": "دیابت", "status": "false" }
|
||||||
|
],
|
||||||
|
"allergies": [
|
||||||
|
{ "substance": "پنیسیلین", "reaction": "کهیر", "severity": "شدید" }
|
||||||
|
],
|
||||||
|
"medications": [
|
||||||
|
{ "name": "لورازپام", "dose": "1mg", "frequency": "شبها قبل خواب" }
|
||||||
|
],
|
||||||
|
"surgeries": [
|
||||||
|
{ "type": "آپاندکتومی", "year": 2015, "hospital": "بیمارستان امام خمینی" }
|
||||||
|
],
|
||||||
|
"family_history": [
|
||||||
|
{ "relation": "پدر", "disease": "فشار خون" }
|
||||||
|
],
|
||||||
|
"relatives": [
|
||||||
|
{
|
||||||
|
"name": "علی",
|
||||||
|
"relation": "پسر عمو",
|
||||||
|
"contact": { "phone": "+989121234567", "email": "...", "address": "..." }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_profiles_user ON profiles(user_id);
|
||||||
|
CREATE INDEX idx_profiles_national_code ON profiles(national_code);
|
||||||
|
```
|
||||||
|
|
||||||
|
## رابطه
|
||||||
|
- `profiles.user_id` → `users.id` (OneToOne, CASCADE DELETE)
|
||||||
|
- `profiles.basic_insurance_id` → `categories.id`
|
||||||
|
- `profiles.supplementary_insurance_id` → `categories.id`
|
||||||
|
|
||||||
|
## نمونه داده واقعی از DB backup
|
||||||
|
```
|
||||||
|
id=1, uid=10, label='hamed', status=1
|
||||||
|
id=3, uid=32, label='hamed', status=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- `field_family` VARCHAR(25) است — نام خانوادگی کوتاه ذخیره میشود
|
||||||
|
- `field_national_code` VARCHAR(10) است — کد ملی ۱۰ رقمی
|
||||||
|
- `date_of_birth` از نوع timestamp (INT) است، نه DATE
|
||||||
|
- `basic_insurance` و `supplementary_insurance` entity reference به جدول categories هستند
|
||||||
|
- `field_sharing_with_user` احتمالاً برای اشتراک پروفایل با دکتر/منشی است
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۰۳: ماژول پروفایل کاربر
|
||||||
|
|
||||||
|
## جریان بعد از ذخیره پروفایل
|
||||||
|
طبق مستندات Drupal، بعد از ذخیره پروفایل باید:
|
||||||
|
1. `POST /oauth/token` با refresh_token اجرا شود (دریافت access_token جدید)
|
||||||
|
2. `GET /oauth/userinfo` اجرا شود
|
||||||
|
|
||||||
|
در Symfony این جریان در سمت **کلاینت** انجام میشود، نه سرور.
|
||||||
|
→ در پاسخ POST /api/v1/user-profile، token های بهروز شده نیز برگردان:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"profile": { "uuid": "...", "name": "..." },
|
||||||
|
"access_token": "eyJ...",
|
||||||
|
"refresh_token": "eyJ..."
|
||||||
|
},
|
||||||
|
"message": "پروفایل با موفقیت ذخیره شد"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## اعتبارسنجی blood_type
|
||||||
|
مقادیر مجاز:
|
||||||
|
```php
|
||||||
|
#[Assert\Choice(choices: [
|
||||||
|
'a_positive', 'a_negative',
|
||||||
|
'b_positive', 'b_negative',
|
||||||
|
'ab_positive', 'ab_negative',
|
||||||
|
'o_positive', 'o_negative'
|
||||||
|
])]
|
||||||
|
```
|
||||||
|
|
||||||
|
## اعتبارسنجی gender
|
||||||
|
```php
|
||||||
|
#[Assert\Choice(choices: ['male', 'female', 'other'])]
|
||||||
|
```
|
||||||
|
|
||||||
|
## اعتبارسنجی education
|
||||||
|
```php
|
||||||
|
#[Assert\Choice(choices: [
|
||||||
|
'primary', 'secondary', 'diploma',
|
||||||
|
'associate', 'bachelor', 'master',
|
||||||
|
'postgraduate_diploma', 'doctorate'
|
||||||
|
])]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Partial Update (PATCH)
|
||||||
|
endpoint PATCH باید فقط فیلدهایی که ارسال شده را آپدیت کند.
|
||||||
|
→ از `$request->request->has('field')` یا DTO با nullable fields استفاده کن.
|
||||||
|
→ مثال: اگر فقط `diseases` در body باشد، بقیه فیلدها تغییر نکنند.
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
POST /api/v1/user-profile → کاربر احراز هویتشده (برای خودش)
|
||||||
|
GET /api/v1/user-profile/{uuid} → owner یا ROLE_ADMIN یا ROLE_DOCTOR (دکتر مرتبط)
|
||||||
|
PATCH /api/v1/user-profile/{uuid} → owner یا ROLE_ADMIN
|
||||||
|
DELETE /api/v1/user-profile/{uuid} → فقط ROLE_ADMIN
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکته UUID در URL
|
||||||
|
در Drupal از UUID واقعی در URL استفاده میشد.
|
||||||
|
در Symfony نیز همان رویکرد حفظ میشود.
|
||||||
|
ParamConverter میتواند UUID را به Entity تبدیل کند:
|
||||||
|
```php
|
||||||
|
#[Route('/api/v1/user-profile/{uuid}', methods: ['GET'])]
|
||||||
|
public function get(UserProfile $userProfile): Response
|
||||||
|
// Doctrine ParamConverter به طور خودکار uuid را به UserProfile تبدیل میکند
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# تسک ۰۳: ماژول پروفایل کاربر
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
پیادهسازی CRUD پروفایل پزشکی کاربر شامل اطلاعات شخصی،
|
||||||
|
سابقه بیماری، آلرژیها، داروها، عملهای جراحی، سابقه خانوادگی و بستگان.
|
||||||
|
|
||||||
|
## Endpoint ها
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/user-profile` | ایجاد پروفایل | بله |
|
||||||
|
| GET | `/api/v1/user-profile/{uuid}` | دریافت پروفایل | بله |
|
||||||
|
| PATCH | `/api/v1/user-profile/{uuid}` | ویرایش پروفایل | بله (Owner/Admin) |
|
||||||
|
| DELETE | `/api/v1/user-profile/{uuid}` | حذف پروفایل | بله (Admin) |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱ و ۰۲ کامل شده باشند
|
||||||
|
|
||||||
|
## خروجیهای مورد انتظار
|
||||||
|
- [ ] Entity پروفایل با تمام فیلدها
|
||||||
|
- [ ] پشتیبانی از JSON column برای دادههای پزشکی پیچیده
|
||||||
|
- [ ] بعد از ذخیره پروفایل: refresh token و get userinfo اجرا میشود
|
||||||
|
- [ ] فقط owner یا admin میتواند پروفایل را ببیند/ویرایش کند
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۸ تا ۱۰ ساعت
|
||||||
|
|
||||||
|
## نمونه Request
|
||||||
|
|
||||||
|
### POST /api/v1/user-profile
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "علی رضایی",
|
||||||
|
"description": [{ "value": "متن توضیحات", "format": "basic_html" }],
|
||||||
|
"birthday": "1370-05-15",
|
||||||
|
"basic_insurance": [251],
|
||||||
|
"blood_type": "ab_negative",
|
||||||
|
"education": "postgraduate_diploma",
|
||||||
|
"fathers_name": "محمد",
|
||||||
|
"gender": "male",
|
||||||
|
"home_phone": "07433332178",
|
||||||
|
"job": "مهندس",
|
||||||
|
"marital_status": "married",
|
||||||
|
"supplementary_insurance": "308",
|
||||||
|
"work_phone": "07433332178",
|
||||||
|
"address": "تهران، خیابان ولیعصر",
|
||||||
|
"other": [{
|
||||||
|
"disease": [{ "id": 1, "name": "فشار خون", "status": "true" }],
|
||||||
|
"allergies": [{ "substance": "پنیسیلین", "reaction": "کهیر", "severity": "شدید" }],
|
||||||
|
"medications": [{ "name": "آتنولول", "dose": "50mg", "frequency": "صبحها" }],
|
||||||
|
"surgeries": [{ "type": "آپاندکتومی", "year": 2015, "hospital": "بیمارستان امام خمینی" }],
|
||||||
|
"family_history": [{ "relation": "پدر", "disease": "فشار خون" }],
|
||||||
|
"relatives": [{
|
||||||
|
"name": "سارا",
|
||||||
|
"relation": "خواهر",
|
||||||
|
"contact": { "phone": "09121234567", "email": "sara@example.com", "address": "تهران" }
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# معماری — تسک ۰۴: ماژول بلاگ
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Blog/
|
||||||
|
├── Controller/
|
||||||
|
│ ├── BlogController.php ← CRUD بلاگ
|
||||||
|
│ └── BlogImageController.php ← آپلود تصویر
|
||||||
|
├── Service/
|
||||||
|
│ ├── BlogService.php
|
||||||
|
│ └── ImageUploadService.php
|
||||||
|
├── Repository/
|
||||||
|
│ └── BlogRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ └── Blog.php
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ ├── CreateBlogRequest.php
|
||||||
|
│ │ └── UpdateBlogRequest.php
|
||||||
|
│ └── Response/
|
||||||
|
│ ├── BlogResponse.php
|
||||||
|
│ └── BlogListResponse.php
|
||||||
|
└── Voter/
|
||||||
|
└── BlogVoter.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Blog
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'blogs')]
|
||||||
|
class Blog
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private User $author;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 300)]
|
||||||
|
private string $title;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 300, unique: true)]
|
||||||
|
private string $slug;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text')]
|
||||||
|
private string $body;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $summary;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, default: 'draft')]
|
||||||
|
private string $status; // draft, published, archived
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', default: 0)]
|
||||||
|
private int $viewCount = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $imagePath;
|
||||||
|
|
||||||
|
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||||
|
#[ORM\JoinTable(name: 'blog_tags')]
|
||||||
|
private Collection $tags;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# پایگاه داده — تسک ۰۴: ماژول بلاگ
|
||||||
|
|
||||||
|
## ساختار واقعی از DB backup
|
||||||
|
|
||||||
|
جدول `blog` در Drupal base fields را دارد + ۳ custom field:
|
||||||
|
|
||||||
|
## جدول: blogs
|
||||||
|
_(entity_type=blog — از DB backup تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | uid | نویسنده |
|
||||||
|
| title | VARCHAR(255) NULL | label | عنوان (base field Drupal) |
|
||||||
|
| slug | VARCHAR(300) UNIQUE NOT NULL | — | اضافهشده در Symfony (در Drupal نیست!) |
|
||||||
|
| body | LONGTEXT NULL | description__value | محتوا (HTML) |
|
||||||
|
| status | TINYINT(1) DEFAULT 0 | status | منتشرشده/پیشنویس |
|
||||||
|
| is_top | TINYINT(1) DEFAULT 0 | field_top | نمایش در صفحه اول |
|
||||||
|
| image_id | INT FK → files.id NULL | field_image | تصویر شاخص (entity ref → file) |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## جدول: blog_tags (ManyToMany)
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| blog_id | INT FK → blogs.id CASCADE | | |
|
||||||
|
| category_id | INT FK → categories.id CASCADE | field_tag | تگ/دستهبندی (entity ref → category/tag bundle) |
|
||||||
|
|
||||||
|
## نمونه داده واقعی از DB backup
|
||||||
|
```
|
||||||
|
id=14, label='روش های خانگی محافظت از پوست در تابستان', uid=22, status=1
|
||||||
|
id=15, 16, ... (22 مطلب)
|
||||||
|
field_image: target_id=97 (file entity)
|
||||||
|
```
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_blogs_status ON blogs(status);
|
||||||
|
CREATE INDEX idx_blogs_user ON blogs(user_id);
|
||||||
|
CREATE INDEX idx_blogs_created ON blogs(created_at DESC);
|
||||||
|
CREATE INDEX idx_blogs_top ON blogs(is_top);
|
||||||
|
CREATE UNIQUE INDEX idx_blogs_slug ON blogs(slug);
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- `slug` در Drupal وجود ندارد — در Symfony باید auto-generate شود از `title`
|
||||||
|
- `image` در Drupal یک entity reference به file/media است — در Symfony مسیر فایل ذخیره میشود
|
||||||
|
- `body` در Drupal با نام `description__value` ذخیره میشود (base field)
|
||||||
|
- `status=1` = منتشرشده، `status=0` = پیشنویس
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۰۴: ماژول بلاگ
|
||||||
|
|
||||||
|
## نگاشت فیلدهای Request → Response (مهم!)
|
||||||
|
|
||||||
|
در Drupal نام فیلدهای **ارسالی** با نام فیلدهای **دریافتی** متفاوت است:
|
||||||
|
|
||||||
|
| فیلد در Request | فیلد در Response | توضیح |
|
||||||
|
|----------------|-----------------|-------|
|
||||||
|
| `label` | `title` | عنوان مقاله |
|
||||||
|
| `description` (string) | `body: {value, format}` | متن مقاله — در response به object تبدیل میشود |
|
||||||
|
| `field_image: [{target_id}]` | `images: [{url, fid, filename, filemime, filesize}]` | تصاویر — ID ارسال، object دریافت |
|
||||||
|
| — | `author` | نام نویسنده (computed از realname کاربر) |
|
||||||
|
| — | `uuid` | شناسه یکتا |
|
||||||
|
| — | `status` | وضعیت انتشار (string: "1") |
|
||||||
|
| — | `created` / `changed` | Unix timestamp به صورت string |
|
||||||
|
|
||||||
|
## فرمت واقعی Response بلاگ (از سرور Drupal — endpoint 93)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "95f6acb0-3331-4141-801e-004e8460edac",
|
||||||
|
"title": "روش های خانگی محافظت از پوست در تابستان",
|
||||||
|
"status": "1",
|
||||||
|
"body": {
|
||||||
|
"value": "متن کامل مقاله...",
|
||||||
|
"format": "full_html"
|
||||||
|
},
|
||||||
|
"created": "1763536163",
|
||||||
|
"changed": "1763537699",
|
||||||
|
"author": "single doctor",
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"url": "https://domain.com/sites/default/files/blog/image.png",
|
||||||
|
"fid": "97",
|
||||||
|
"filename": "image.png",
|
||||||
|
"filemime": "image/png",
|
||||||
|
"filesize": 320787
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tag": [
|
||||||
|
{
|
||||||
|
"uuid": "24926497-fc2d-47d7-82ae-26cbbc4d6468",
|
||||||
|
"id": "2501",
|
||||||
|
"name": "مجله"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "6c6a488b-1051-43a2-887b-0ec7a05e52d5",
|
||||||
|
"id": "2500",
|
||||||
|
"name": "سلامتی"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **نکات مهم response:**
|
||||||
|
> - `body` یک **object** است با کلیدهای `value` و `format` — نه string ساده!
|
||||||
|
> - `format` معمولاً `"full_html"` یا `"basic_html"` است
|
||||||
|
> - `author` از `realname` کاربر نویسنده computed میشود
|
||||||
|
> - `created` و `changed` به صورت **string** برگردانده میشوند (نه integer)
|
||||||
|
> - `status` به صورت string `"1"` برگردانده میشود (نه boolean)
|
||||||
|
> - `images` و `tag` ممکن است آرایه خالی `[]` باشند
|
||||||
|
> - response دارای `id` نیست — فقط `uuid`
|
||||||
|
|
||||||
|
## فرمت Request ایجاد/ویرایش بلاگ
|
||||||
|
|
||||||
|
### POST /api/v1/blog/
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "عنوان مقاله",
|
||||||
|
"description": "متن کامل مقاله...",
|
||||||
|
"field_image": [
|
||||||
|
{ "target_id": 97 },
|
||||||
|
{ "target_id": 98 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PATCH /api/v1/blog/{uuid}
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"label": "عنوان ویرایششده",
|
||||||
|
"description": "متن ویرایششده"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## آپلود تصویر بلاگ (endpoint 99)
|
||||||
|
قبل از ایجاد بلاگ، تصویر باید آپلود شود و `fid` آن در `field_image` استفاده شود:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /file/upload/blog/blog/field_image
|
||||||
|
Headers:
|
||||||
|
Content-Type: application/octet-stream
|
||||||
|
Content-Disposition: file; filename="image.png"
|
||||||
|
X-CSRF-Token: {token}
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Response → fid که در field_image استفاده میشود
|
||||||
|
```
|
||||||
|
|
||||||
|
## تولید Slug
|
||||||
|
- از عنوان فارسی slug تولید کن
|
||||||
|
- پکیج `cocur/slugify` را نصب کن:
|
||||||
|
```bash
|
||||||
|
ddev composer require cocur/slugify
|
||||||
|
```
|
||||||
|
- اگر slug تکراری بود، عدد به انتهای آن اضافه کن: `rahnamai-diabet-2`
|
||||||
|
|
||||||
|
## بلاگهای برتر (Top Blogs)
|
||||||
|
- endpoint: `GET /api/v1/blogs/top` (نیاز به auth دارد)
|
||||||
|
- پاسخ: آرایه مستقیم (نه object با pagination) از بلاگهایی که `is_top = 1` هستند
|
||||||
|
- فرمت هر آیتم دقیقاً مشابه response معمولی بلاگ است
|
||||||
|
|
||||||
|
## لیست بلاگها با فیلتر
|
||||||
|
```
|
||||||
|
GET /api/v1/blogs?page=1&limit=10&title=نشانه&tag=3637
|
||||||
|
```
|
||||||
|
- `title`: جستجو در عنوان
|
||||||
|
- `tag`: فیلتر بر اساس ID تگ
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
```php
|
||||||
|
$offset = ($page - 1) * $limit;
|
||||||
|
// در Repository با limit/offset
|
||||||
|
```
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
POST → احراز هویت الزامی (ROLE_DOCTOR یا ROLE_ADMIN)
|
||||||
|
PATCH → احراز هویت الزامی (owner یا ROLE_ADMIN)
|
||||||
|
DELETE → احراز هویت الزامی (owner یا ROLE_ADMIN)
|
||||||
|
GET → عمومی (بدون auth)
|
||||||
|
GET /api/v1/blogs/top → احراز هویت الزامی
|
||||||
|
```
|
||||||
|
|
||||||
|
## بهینهسازی
|
||||||
|
- view_count با یک query atomic آپدیت کن تا race condition نباشد:
|
||||||
|
```php
|
||||||
|
$this->em->createQuery('UPDATE Blog b SET b.viewCount = b.viewCount + 1 WHERE b.id = :id')
|
||||||
|
->setParameter('id', $blog->getId())
|
||||||
|
->execute();
|
||||||
|
```
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# تسک ۰۴: ماژول بلاگ
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
پیادهسازی سیستم مدیریت مقالات (بلاگ) شامل ایجاد، ویرایش، حذف،
|
||||||
|
نمایش لیست، بلاگهای برتر و آپلود تصویر.
|
||||||
|
|
||||||
|
## Endpoint ها
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/blog/` | ایجاد بلاگ جدید | بله (Admin/Doctor) |
|
||||||
|
| PATCH | `/api/v1/blog/{uuid}` | ویرایش بلاگ | بله (Owner/Admin) |
|
||||||
|
| DELETE | `/api/v1/blog/{uuid}` | حذف بلاگ | بله (Owner/Admin) |
|
||||||
|
| GET | `/api/v1/blog/{uuid}` | دریافت یک بلاگ | خیر |
|
||||||
|
| GET | `/api/v1/blogs` | لیست بلاگها (با pagination) | خیر |
|
||||||
|
| GET | `/api/v1/blogs/top` | بلاگهای برتر | خیر |
|
||||||
|
| POST | `/api/v1/blog/image` | آپلود تصویر بلاگ | بله |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱ و ۰۲
|
||||||
|
|
||||||
|
## خروجیهای مورد انتظار
|
||||||
|
- [ ] CRUD کامل برای بلاگ
|
||||||
|
- [ ] pagination در لیست
|
||||||
|
- [ ] آپلود و ذخیره تصویر
|
||||||
|
- [ ] بلاگهای برتر (براساس بازدید یا لایک)
|
||||||
|
- [ ] slug برای SEO
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۶ تا ۸ ساعت
|
||||||
|
|
||||||
|
## نمونه Request/Response
|
||||||
|
|
||||||
|
### POST /api/v1/blog/
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"title": "راهنمای کامل دیابت",
|
||||||
|
"body": "<p>محتوای مقاله...</p>",
|
||||||
|
"summary": "خلاصه مقاله",
|
||||||
|
"tags": [1, 2, 3],
|
||||||
|
"status": "published",
|
||||||
|
"image_uuid": "abc-..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 201
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"uuid": "72522a1d-...",
|
||||||
|
"title": "راهنمای کامل دیابت",
|
||||||
|
"slug": "rahnamai-kamel-diabet",
|
||||||
|
"status": "published",
|
||||||
|
"created_at": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/blogs
|
||||||
|
```
|
||||||
|
Query params: page=1&limit=10&category=1&tag=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/blogs/top
|
||||||
|
```json
|
||||||
|
// Response
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "uuid": "...", "title": "...", "views": 1250, "image": "..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# معماری — تسک ۰۵: ماژول دکتر
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Doctor/
|
||||||
|
├── Controller/
|
||||||
|
│ ├── DoctorController.php ← CRUD دکتر + لیست + جستجو
|
||||||
|
│ ├── DoctorImageController.php ← آپلود تصویر
|
||||||
|
│ └── DoctorAddressController.php ← CRUD آدرس مطب
|
||||||
|
├── Service/
|
||||||
|
│ ├── DoctorService.php
|
||||||
|
│ └── DoctorAddressService.php
|
||||||
|
├── Repository/
|
||||||
|
│ ├── DoctorRepository.php
|
||||||
|
│ └── DoctorAddressRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ ├── Doctor.php
|
||||||
|
│ └── DoctorAddress.php
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ ├── CreateDoctorRequest.php
|
||||||
|
│ │ ├── UpdateDoctorRequest.php
|
||||||
|
│ │ ├── DoctorFilterRequest.php
|
||||||
|
│ │ ├── CreateDoctorAddressRequest.php
|
||||||
|
│ │ └── UpdateDoctorAddressRequest.php
|
||||||
|
│ └── Response/
|
||||||
|
│ ├── DoctorResponse.php ← view کامل با آمار
|
||||||
|
│ ├── DoctorListItemResponse.php ← لیست/جستجو
|
||||||
|
│ └── DoctorAddressResponse.php
|
||||||
|
└── Voter/
|
||||||
|
└── DoctorVoter.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Doctor (بر اساس فیلدهای واقعی Drupal)
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'doctors')]
|
||||||
|
class Doctor
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\OneToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private User $user; // uid در Drupal
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100)]
|
||||||
|
private string $name; // field_name
|
||||||
|
|
||||||
|
#[ORM\Column(length: 10, nullable: true)]
|
||||||
|
private ?string $gender; // field_gender
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
private string $medicalSystemCode; // field_doctor_id (شماره نظام پزشکی)
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', nullable: true)]
|
||||||
|
private ?int $activityTime; // field_activity_time (timestamp سال شروع)
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $degree; // field_degree
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $info; // field_info
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $imagePath; // field_image (media)
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'decimal', precision: 3, scale: 1, options: ['default' => 3.5])]
|
||||||
|
private float $doctorRate = 3.5; // field_doctor_rate
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'decimal', precision: 5, scale: 1, options: ['default' => 60])]
|
||||||
|
private float $doctorRatePercentage = 60; // field_doctor_rate_percentage
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => true])]
|
||||||
|
private bool $activeDoctorAppointment = true; // field_active_doctor_appointmen
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Representation::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true)]
|
||||||
|
private ?Representation $representation; // field_representation (multi-tenant)
|
||||||
|
|
||||||
|
// ManyToMany relations (field_specialty, field_doctor_services, field_state, field_city)
|
||||||
|
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||||
|
#[ORM\JoinTable(name: 'doctor_specialties')]
|
||||||
|
private Collection $specialties; // field_specialty (چند مقداری)
|
||||||
|
|
||||||
|
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||||
|
#[ORM\JoinTable(name: 'doctor_services')]
|
||||||
|
private Collection $services; // field_doctor_services (چند مقداری)
|
||||||
|
|
||||||
|
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||||
|
#[ORM\JoinTable(name: 'doctor_states')]
|
||||||
|
private Collection $states; // field_state
|
||||||
|
|
||||||
|
#[ORM\ManyToMany(targetEntity: Category::class)]
|
||||||
|
#[ORM\JoinTable(name: 'doctor_cities')]
|
||||||
|
private Collection $cities; // field_city
|
||||||
|
|
||||||
|
#[ORM\OneToMany(targetEntity: DoctorAddress::class, mappedBy: 'doctor')]
|
||||||
|
private Collection $addresses;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: DoctorAddress (بر اساس فیلدهای واقعی Drupal)
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'doctor_addresses')]
|
||||||
|
class DoctorAddress
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Doctor::class, inversedBy: 'addresses')]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private Doctor $doctor; // field_doctor
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $name; // field_name (نام مطب/شعبه)
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $address; // field_address
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
|
private ?string $telephone; // field_telephone (نه phone!)
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'decimal', precision: 10, scale: 8, nullable: true)]
|
||||||
|
private ?float $latitude; // field_latitude
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'decimal', precision: 11, scale: 8, nullable: true)]
|
||||||
|
private ?float $longitude; // field_longitude
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response کامل دکتر (finalizeData)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 5, "uuid": "...",
|
||||||
|
"name": "دکتر محمدی",
|
||||||
|
"gender": "male",
|
||||||
|
"experience": 12,
|
||||||
|
"activity_time": 1388534400,
|
||||||
|
"medical_system_code": "12345",
|
||||||
|
"detail": "متخصص قلب و عروق...",
|
||||||
|
"degree": "دکترای تخصصی",
|
||||||
|
"specialties": [{"id": 3, "uuid": "...", "name": "قلب و عروق"}],
|
||||||
|
"img": ["https://..."],
|
||||||
|
"expertise": [{"id": 10, "uuid": "...", "name": "اکوکاردیوگرافی"}],
|
||||||
|
"satisfaction": 87.5,
|
||||||
|
"point": 4.4,
|
||||||
|
"free_turn": "اولین نوبت آزاد: دوشنبه ۱۲ اردیبهشت ساعت ۱۰:۳۰",
|
||||||
|
"hours_of_work": ["شنبه", "یکشنبه", "دوشنبه"],
|
||||||
|
"address": [...],
|
||||||
|
"average_rate": {"average_stars": 4.3, "total_rates": 87},
|
||||||
|
"state": [{"id": 2, "uuid": "...", "name": "فارس"}],
|
||||||
|
"city": [{"id": 5, "uuid": "...", "name": "شیراز"}]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## منطق create دکتر (از DoctorService.php)
|
||||||
|
۱. اگر `doctor_mobile_number` داده شد:
|
||||||
|
- کاربر با این موبایل را پیدا کن
|
||||||
|
- اگر نقش `doctor` ندارد → 403
|
||||||
|
- اگر قبلاً profile دکتر دارد → 403
|
||||||
|
- اگر کاربر وجود ندارد → کاربر جدید با نقش doctor بساز
|
||||||
|
۲. اگر `doctor_mobile_number` نداده شد → کاربر جاری استفاده شود
|
||||||
|
۳. اعتبارسنجی همه فیلدهای اجباری
|
||||||
|
۴. ایجاد entity
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# پایگاه داده — تسک ۰۵: ماژول دکتر
|
||||||
|
|
||||||
|
## جدول: doctors
|
||||||
|
_(entity_type=clinic_pro, bundle=doctor — از DB backup و config تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id UNIQUE NOT NULL | uid | کاربر صاحب پروفایل |
|
||||||
|
| name | VARCHAR(255) NOT NULL | field_name | نام دکتر |
|
||||||
|
| gender | VARCHAR(10) NULL | field_gender | `woman` یا `man` |
|
||||||
|
| medical_system_code | VARCHAR(25) NULL | field_doctor_id | شماره نظام پزشکی (max 25) |
|
||||||
|
| mobile_number | VARCHAR(15) NULL | field_doctor_mobile_number | شماره موبایل دکتر (max 15) |
|
||||||
|
| activity_time | INT NULL | field_activity_time | timestamp سال شروع فعالیت |
|
||||||
|
| degree | VARCHAR(30) NULL | field_degree | مدرک (مقادیر زیر) |
|
||||||
|
| info | LONGTEXT NULL | field_info | بیوگرافی |
|
||||||
|
| image_path | VARCHAR(255) NULL | field_image | تصویر (media) |
|
||||||
|
| doctor_rate | FLOAT NULL | field_doctor_rate | امتیاز ستارهای (default: 3.5) |
|
||||||
|
| doctor_rate_percentage | FLOAT NULL | field_doctor_rate_percentage | درصد رضایت (default: 60) |
|
||||||
|
| active_doctor_appointment | TINYINT(1) DEFAULT 1 | field_active_doctor_appointmen | نوبتدهی فعال (بدون حرف 't') |
|
||||||
|
| representation_id | INT FK → representations.id NULL | field_representation | entity ref → clinic_pro |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## مقادیر field_degree (از config)
|
||||||
|
```
|
||||||
|
expert → کارشناس
|
||||||
|
general → پزشک عمومی
|
||||||
|
specialist → پزشک متخصص
|
||||||
|
subspecialistplus → پزشک فوق تخصص
|
||||||
|
```
|
||||||
|
|
||||||
|
## مقادیر field_gender (از config)
|
||||||
|
```
|
||||||
|
woman → زن
|
||||||
|
man → مرد
|
||||||
|
```
|
||||||
|
|
||||||
|
## جدول: doctor_specialties (ManyToMany pivot)
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| doctor_id | INT FK → doctors.id | |
|
||||||
|
| category_id | INT FK → categories.id | |
|
||||||
|
| PRIMARY KEY (doctor_id, category_id) | | field_specialty → cardinality=2 (حداکثر ۲ تخصص) |
|
||||||
|
|
||||||
|
## جدول: doctor_services (ManyToMany pivot)
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| doctor_id | INT FK → doctors.id | |
|
||||||
|
| category_id | INT FK → categories.id | |
|
||||||
|
| PRIMARY KEY (doctor_id, category_id) | | field_doctor_services → cardinality=8 (حداکثر ۸ سرویس) |
|
||||||
|
|
||||||
|
## جدول: doctor_states (ManyToMany pivot)
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| doctor_id | INT FK → doctors.id | |
|
||||||
|
| category_id | INT FK → categories.id | |
|
||||||
|
| PRIMARY KEY (doctor_id, category_id) | | field_state → cardinality=1 |
|
||||||
|
|
||||||
|
## جدول: doctor_cities (ManyToMany pivot)
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| doctor_id | INT FK → doctors.id | |
|
||||||
|
| category_id | INT FK → categories.id | |
|
||||||
|
| PRIMARY KEY (doctor_id, category_id) | | field_city → cardinality=1 |
|
||||||
|
|
||||||
|
## جدول: doctor_addresses
|
||||||
|
_(entity_type=clinic_pro, bundle=doctors_addresses — از DB backup)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| doctor_id | INT FK → doctors.id CASCADE | field_doctor | دکتر |
|
||||||
|
| name | VARCHAR(255) NULL | field_name | نام مطب/آدرس |
|
||||||
|
| address | LONGTEXT NULL | field_address | آدرس کامل |
|
||||||
|
| telephone | VARCHAR(50) NULL | field_telephone | تلفن مطب |
|
||||||
|
| latitude | FLOAT NULL | field_latitude | عرض جغرافیایی |
|
||||||
|
| longitude | FLOAT NULL | field_longitude | طول جغرافیایی |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## جدول: clinics
|
||||||
|
_(entity_type=clinic_pro, bundle=clinic — از config تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | uid | مالک |
|
||||||
|
| name | VARCHAR(255) NULL | field_name | نام کلینیک |
|
||||||
|
| info | LONGTEXT NULL | field_info | توضیحات |
|
||||||
|
| address | LONGTEXT NULL | field_address | آدرس |
|
||||||
|
| telephone | VARCHAR(50) NULL | field_telephone | تلفن |
|
||||||
|
| is_24_7 | TINYINT(1) DEFAULT 0 | field_24_7 | باز ۲۴/۷ |
|
||||||
|
| working_days | VARCHAR(255) NULL | field_working_days | روزهای کاری (متن آزاد، مثال: 'شنبه تا سه شنبه ساعت ۱۲:۲۰') |
|
||||||
|
| latitude | FLOAT NULL | field_latitude | |
|
||||||
|
| longitude | FLOAT NULL | field_longitude | |
|
||||||
|
| city_id | INT FK → categories.id NULL | field_city | |
|
||||||
|
| state_id | INT FK → categories.id NULL | field_state | |
|
||||||
|
| representation_id | INT FK → representations.id NULL | field_agent | نماینده کلینیک |
|
||||||
|
| created_at | INT NOT NULL | created | |
|
||||||
|
| updated_at | INT NOT NULL | changed | |
|
||||||
|
|
||||||
|
جداول pivot مرتبط با clinic:
|
||||||
|
- `clinic_doctors` (clinic_id, doctor_id) → field_doctors (cardinality نامحدود)
|
||||||
|
- `clinic_specialties` (clinic_id, category_id) → field_clinic_specialty
|
||||||
|
- `clinic_services` (clinic_id, category_id) → field_doctor_services
|
||||||
|
- `clinic_insurances` (clinic_id, category_id) → field_insurance
|
||||||
|
|
||||||
|
## جدول: doctor_secretaries
|
||||||
|
_(entity_type=clinic_pro, bundle=doctor_secretary — از config تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | uid | |
|
||||||
|
| doctor_id | INT FK → doctors.id NOT NULL | field_doctor | entity ref → clinic_pro/doctor |
|
||||||
|
| secretary_id | INT FK → users.id NOT NULL | field_secretary | entity ref → user (منشی) |
|
||||||
|
| telephone | VARCHAR(50) NULL | field_telephone | تلفن |
|
||||||
|
| permission | LONGTEXT NULL | field_permission | مجوزها (JSON) |
|
||||||
|
| active | TINYINT(1) DEFAULT 1 | field_active | فعال/غیرفعال |
|
||||||
|
| created_at | INT NOT NULL | created | |
|
||||||
|
| updated_at | INT NOT NULL | changed | |
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_doctors_user ON doctors(user_id);
|
||||||
|
CREATE INDEX idx_doctors_representation ON doctors(representation_id);
|
||||||
|
CREATE INDEX idx_doctors_active ON doctors(active_doctor_appointment);
|
||||||
|
CREATE INDEX idx_doctors_rate ON doctors(doctor_rate DESC);
|
||||||
|
CREATE INDEX idx_doctor_addresses_doctor ON doctor_addresses(doctor_id);
|
||||||
|
CREATE INDEX idx_clinics_user ON clinics(user_id);
|
||||||
|
CREATE UNIQUE INDEX idx_doctor_secretaries_doctor_secretary ON doctor_secretaries(doctor_id, secretary_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## نمونه داده واقعی از DB (clinic_pro table)
|
||||||
|
```
|
||||||
|
id=18, bundle='doctor'
|
||||||
|
id=22, bundle='doctor'
|
||||||
|
id=23, bundle='clinic'
|
||||||
|
id=24, bundle='clinic'
|
||||||
|
id=27, bundle='doctor_secretary'
|
||||||
|
id=29, bundle='doctor'
|
||||||
|
id=38, bundle='doctors_addresses'
|
||||||
|
id=41, bundle='representation'
|
||||||
|
```
|
||||||
|
|
||||||
|
## روابط کامل clinic_pro entity
|
||||||
|
- `doctors` → `users` (ManyToOne, UNIQUE → OneToOne)
|
||||||
|
- `doctors` → `representations` (ManyToOne)
|
||||||
|
- `doctors` ↔ `categories` (ManyToMany: specialties, services, states, cities)
|
||||||
|
- `doctor_addresses` → `doctors` (ManyToOne, CASCADE)
|
||||||
|
- `clinics` → `users` (ManyToOne)
|
||||||
|
- `clinics` ↔ `doctors` (ManyToMany)
|
||||||
|
- `clinics` ↔ `categories` (ManyToMany)
|
||||||
|
- `doctor_secretaries` → `doctors` (ManyToOne)
|
||||||
|
- `doctor_secretaries` → `users` as secretary (ManyToOne)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۰۵: ماژول دکتر
|
||||||
|
|
||||||
|
## فیلتر لیست دکترها
|
||||||
|
لیست دکترها باید فیلترهای زیر را پشتیبانی کند:
|
||||||
|
```php
|
||||||
|
// DoctorRepository.php
|
||||||
|
public function findFiltered(DoctorFilterRequest $filter): array
|
||||||
|
{
|
||||||
|
$qb = $this->createQueryBuilder('d')
|
||||||
|
->join('d.user', 'u');
|
||||||
|
|
||||||
|
if ($filter->specialty) {
|
||||||
|
$qb->andWhere('d.specialty = :specialty')
|
||||||
|
->setParameter('specialty', $filter->specialty);
|
||||||
|
}
|
||||||
|
if ($filter->city) {
|
||||||
|
$qb->join('d.addresses', 'a')
|
||||||
|
->andWhere('a.city = :city')
|
||||||
|
->setParameter('city', $filter->city);
|
||||||
|
}
|
||||||
|
if ($filter->name) {
|
||||||
|
$qb->andWhere('u.firstName LIKE :name OR u.lastName LIKE :name')
|
||||||
|
->setParameter('name', '%'.$filter->name.'%');
|
||||||
|
}
|
||||||
|
if ($filter->insurance) {
|
||||||
|
$qb->andWhere('JSON_CONTAINS(d.insurances, :ins) = 1')
|
||||||
|
->setParameter('ins', json_encode([$filter->insurance]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $qb->getQuery()->getResult();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## آپدیت میانگین امتیاز
|
||||||
|
وقتی یک rating جدید ثبت میشود (تسک ۱۲)، average_rating را آپدیت کن:
|
||||||
|
```php
|
||||||
|
// در RatingService (تسک ۱۲)
|
||||||
|
$this->em->createQuery(
|
||||||
|
'UPDATE Doctor d SET d.averageRating = (
|
||||||
|
SELECT AVG(r.score) FROM Rating r WHERE r.doctor = d
|
||||||
|
), d.reviewCount = (
|
||||||
|
SELECT COUNT(r.id) FROM Rating r WHERE r.doctor = d
|
||||||
|
) WHERE d.id = :id'
|
||||||
|
)->setParameter('id', $doctor->getId())->execute();
|
||||||
|
```
|
||||||
|
|
||||||
|
## آدرس مطب
|
||||||
|
- یک دکتر میتواند چندین آدرس مطب داشته باشد
|
||||||
|
- `{doctorId}` در endpoint لیست آدرسها، UUID دکتر است (نه ID)
|
||||||
|
- دکتر میتواند آدرسهای خودش را ویرایش/حذف کند
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
POST /api/v1/doctor → ROLE_ADMIN
|
||||||
|
PATCH /api/v1/doctor/{uuid} → owner (دکتر خودش) یا ROLE_ADMIN
|
||||||
|
GET /api/v1/doctor/{uuid} → عمومی
|
||||||
|
GET /api/v1/doctors → عمومی
|
||||||
|
|
||||||
|
POST doctor-address → دکتر احراز هویتشده (برای خودش)
|
||||||
|
PATCH doctor-address/{id} → owner یا ROLE_ADMIN
|
||||||
|
DELETE doctor-address/{id} → owner یا ROLE_ADMIN
|
||||||
|
GET doctor-address/{id} → عمومی
|
||||||
|
GET doctor-addresses/{doctorId} → عمومی
|
||||||
|
```
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# تسک ۰۵: ماژول دکتر
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
پیادهسازی مدیریت پروفایل دکترها، لیست دکترها با فیلتر،
|
||||||
|
آپلود تصویر پروفایل و مدیریت آدرسهای مطب.
|
||||||
|
|
||||||
|
## Endpoint ها (واقعی از Drupal)
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/doctor` | ایجاد پروفایل دکتر | بله |
|
||||||
|
| PATCH | `/api/v1/doctor/{uuid}` | ویرایش پروفایل دکتر | بله (Owner/Admin) |
|
||||||
|
| DELETE | `/api/v1/doctor/{uuid}` | حذف دکتر | بله (Admin) |
|
||||||
|
| GET | `/api/v1/doctor/{uuid}` | دریافت پروفایل کامل دکتر | خیر |
|
||||||
|
| GET | `/api/v1/doctors` | لیست دکترها با فیلتر | خیر |
|
||||||
|
| POST | `/file/upload/clinic_pro/doctor/field_image` | آپلود تصویر پروفایل دکتر | بله |
|
||||||
|
| GET | `/api/v1/clinic/doctor-list/{clinic_uuid}` | لیست دکترهای یک کلینیک | خیر |
|
||||||
|
| POST | `/api/v1/clinic-pro/doctor-address` | ایجاد آدرس مطب | بله |
|
||||||
|
| PATCH | `/api/v1/clinic-pro/doctor-address/{id}` | ویرایش آدرس | بله |
|
||||||
|
| DELETE | `/api/v1/clinic-pro/doctor-address/{id}` | حذف آدرس | بله |
|
||||||
|
| GET | `/api/v1/clinic-pro/doctor-address/{id}` | دریافت یک آدرس | بله |
|
||||||
|
| GET | `/api/v1/clinic-pro/doctor-addresses/{doctorId}` | لیست آدرسهای دکتر | خیر |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱، ۰۲، ۰۸ (Categories برای تخصص و سرویسها)
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۸ تا ۱۰ ساعت
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نمونه واقعی Response — GET /api/v1/doctor/{uuid}
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "29",
|
||||||
|
"uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a",
|
||||||
|
"name": "single doctor",
|
||||||
|
"gender": "woman",
|
||||||
|
"experience": 21,
|
||||||
|
"activity_time": "1107808200",
|
||||||
|
"medical_system_code": "121212121212",
|
||||||
|
"detail": "test",
|
||||||
|
"degree": "specialist",
|
||||||
|
"specialties": [
|
||||||
|
{
|
||||||
|
"uuid": "d60a269d-f7d7-4589-8eab-451e6f740d40",
|
||||||
|
"id": "603",
|
||||||
|
"name": "داخلی عمومی",
|
||||||
|
"parent": "602"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"img": [
|
||||||
|
{
|
||||||
|
"url": "https://domain.com/sites/default/files/doctors/2025-11/image.png",
|
||||||
|
"fid": "98",
|
||||||
|
"filename": "image.png",
|
||||||
|
"filemime": "image/png",
|
||||||
|
"filesize": 173665
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expertise": [
|
||||||
|
{ "uuid": "...", "id": "1601", "name": "معاینه و تشخیص پزشک متخصص" },
|
||||||
|
{ "uuid": "...", "id": "1602", "name": "ویزیت تخصصی" }
|
||||||
|
],
|
||||||
|
"satisfaction": "60",
|
||||||
|
"point": "3.5",
|
||||||
|
"free_turn": "اولین نوبت آزاد: سهشنبه 19 خرداد ساعت 15:00",
|
||||||
|
"hours_of_work": "از شنبه تا چهارشنبه از ساعت 08:00 تا 18:00",
|
||||||
|
"address": [
|
||||||
|
{
|
||||||
|
"id": "39",
|
||||||
|
"uuid": "7b759d2a-af8a-4730-8eb0-e77dcd3a724e",
|
||||||
|
"name": "مطب اصلی",
|
||||||
|
"map": { "latitude": "53.121212", "longitude": "57.121212" },
|
||||||
|
"address": "آدرس کامل مطب",
|
||||||
|
"telephone": "09120671756"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"average_rate": { "total_rates": null },
|
||||||
|
"state": [{ "uuid": "...", "id": "23", "name": "کهگیلویه و بویراحمد" }],
|
||||||
|
"city": [{ "uuid": "...", "id": "123", "name": "یاسوج", "parent": "23" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **توجه فیلدها:**
|
||||||
|
> - `img` (نه `image` یا `images`!) — آرایه با url/fid/filename/filemime/filesize
|
||||||
|
> - `specialties` → تخصص اصلی + والد
|
||||||
|
> - `expertise` → همان doctor_services
|
||||||
|
> - `satisfaction` و `point` به صورت **string** برگردانده میشوند
|
||||||
|
> - `free_turn` → متن محاسبهشده از برنامه هفتگی (مثلاً "نوبت آزادی موجود نیست")
|
||||||
|
> - `hours_of_work` → متن محاسبهشده از برنامه هفتگی
|
||||||
|
> - `average_rate.total_rates` → میتواند null باشد
|
||||||
|
> - `experience` → محاسبهشده از `activity_time` (Unix timestamp شروع فعالیت)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فیلترهای GET /api/v1/doctors
|
||||||
|
|
||||||
|
| پارامتر | نوع | الزامی | مثال |
|
||||||
|
|---------|-----|--------|------|
|
||||||
|
| `state` | string | بله | `31` |
|
||||||
|
| `city` | string | بله | `62` |
|
||||||
|
| `specialty` | string | خیر | `503` |
|
||||||
|
| `gender` | string | خیر | `man` یا `woman` |
|
||||||
|
| `degree` | string | خیر | `general`, `specialist`, `expert`, `subspecialistplus` |
|
||||||
|
| `name` | string | خیر | `علی` |
|
||||||
|
| `active` | array | خیر | `0` یا `1` |
|
||||||
|
| `page` | string | خیر | `1` |
|
||||||
|
| `limit` | string | خیر | `10` |
|
||||||
|
| `sort` | string | خیر | `ASC` یا `DESC` |
|
||||||
|
|
||||||
|
## نمونه Response لیست دکترها
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "29",
|
||||||
|
"uuid": "...",
|
||||||
|
"name": "single doctor",
|
||||||
|
"gender": "woman",
|
||||||
|
"degree": "specialist",
|
||||||
|
"img": [{ "url": "...", "fid": "98", "filename": "image.png", "filemime": "image/png", "filesize": 173665 }],
|
||||||
|
"specialties": [{ "uuid": "...", "id": "603", "name": "داخلی عمومی", "parent": "602" }],
|
||||||
|
"satisfaction": "60",
|
||||||
|
"point": "3.5",
|
||||||
|
"free_turn": "اولین نوبت آزاد: سهشنبه 19 خرداد ساعت 15:00",
|
||||||
|
"hours_of_work": "از شنبه تا چهارشنبه از ساعت 08:00 تا 18:00",
|
||||||
|
"active": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": {
|
||||||
|
"totalRecords": 7,
|
||||||
|
"totalPages": 1,
|
||||||
|
"currentPage": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PATCH /api/v1/doctor/{uuid} — فیلدهای قابل ویرایش
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "نام دکتر",
|
||||||
|
"doctor_services": ["اکوکاردیوگرافی", "ویزیت تخصصی"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## آپلود تصویر دکتر — POST /file/upload/clinic_pro/doctor/field_image
|
||||||
|
```
|
||||||
|
Headers:
|
||||||
|
Content-Type: application/octet-stream
|
||||||
|
Content-Disposition: file; filename="doctor.png"
|
||||||
|
X-CSRF-Token: {token}
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Body: binary file content
|
||||||
|
Response → { fid, uuid, ... } که در PATCH doctor استفاده میشود
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نمونه واقعی Response — GET /api/v1/clinic-pro/doctor-address/{id}
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "39",
|
||||||
|
"uuid": "7b759d2a-af8a-4730-8eb0-e77dcd3a724e",
|
||||||
|
"name": "مطب اصلی",
|
||||||
|
"map": {
|
||||||
|
"latitude": "53.121212",
|
||||||
|
"longitude": "57.121212"
|
||||||
|
},
|
||||||
|
"address": "آذربايجان غربي، مياندوآب، خيابان ۱۵ خرداد، برج ماندگار",
|
||||||
|
"telephone": "09120671756"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# معماری — تسک ۰۶: ماژول کلینیک
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Clinic/
|
||||||
|
├── Controller/
|
||||||
|
│ ├── ClinicController.php ← CRUD + لیست
|
||||||
|
│ └── ClinicImageController.php ← آپلود تصویر و لوگو
|
||||||
|
├── Service/
|
||||||
|
│ └── ClinicService.php
|
||||||
|
├── Repository/
|
||||||
|
│ └── ClinicRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ ├── Clinic.php
|
||||||
|
│ └── ClinicDoctor.php ← رابطه کلینیک-دکتر
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ ├── CreateClinicRequest.php
|
||||||
|
│ │ └── UpdateClinicRequest.php
|
||||||
|
│ └── Response/
|
||||||
|
│ ├── ClinicResponse.php
|
||||||
|
│ └── ClinicDoctorListResponse.php
|
||||||
|
└── Voter/
|
||||||
|
└── ClinicVoter.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Clinic
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'clinics')]
|
||||||
|
class Clinic
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
private User $owner;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 200)]
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $description;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, nullable: true)]
|
||||||
|
private ?string $phone;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $address;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $city;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $imagePath; // تصویر اصلی
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
private ?string $logoPath; // لوگو
|
||||||
|
|
||||||
|
#[ORM\ManyToMany(targetEntity: Doctor::class)]
|
||||||
|
#[ORM\JoinTable(name: 'clinic_doctors')]
|
||||||
|
private Collection $doctors;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# پایگاه داده — تسک ۰۶: ماژول کلینیک
|
||||||
|
|
||||||
|
## جدول: clinics
|
||||||
|
_(entity_type=clinic_pro, bundle=clinic — از config تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | uid | مالک کلینیک |
|
||||||
|
| name | VARCHAR(255) NULL | field_name | نام کلینیک |
|
||||||
|
| info | LONGTEXT NULL | field_info | توضیحات (نه description!) |
|
||||||
|
| address | LONGTEXT NULL | field_address | آدرس |
|
||||||
|
| telephone | VARCHAR(50) NULL | field_telephone | تلفن (نه phone!) |
|
||||||
|
| is_24_7 | TINYINT(1) DEFAULT 0 | field_24_7 | باز ۲۴/۷ |
|
||||||
|
| working_days | VARCHAR(255) NULL | field_working_days | روزهای کاری (متن آزاد، مثال: 'شنبه تا سه شنبه ساعت ۱۲:۲۰') |
|
||||||
|
| latitude | FLOAT NULL | field_latitude | |
|
||||||
|
| longitude | FLOAT NULL | field_longitude | |
|
||||||
|
| logo_id | INT FK → files.id NULL | field_clinic_logo | لوگو کلینیک (image, cardinality=1) |
|
||||||
|
| city_id | INT FK → categories.id NULL | field_city | شهر (entity ref → category/city) |
|
||||||
|
| state_id | INT FK → categories.id NULL | field_state | استان (entity ref → category/state) |
|
||||||
|
| representation_id | INT FK → representations.id NULL | field_agent | نماینده کلینیک (entity ref → user — در Drupal به user اشاره دارد، در Symfony به representation) |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## جدول: clinic_doctors (ManyToMany)
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| clinic_id | INT FK → clinics.id CASCADE | | |
|
||||||
|
| doctor_id | INT FK → doctors.id CASCADE | field_doctors | cardinality نامحدود |
|
||||||
|
|
||||||
|
## جدول: clinic_specialties (ManyToMany)
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| clinic_id | INT FK → clinics.id CASCADE | |
|
||||||
|
| category_id | INT FK → categories.id | field_clinic_specialty |
|
||||||
|
|
||||||
|
## جدول: clinic_services (ManyToMany)
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| clinic_id | INT FK → clinics.id CASCADE | |
|
||||||
|
| category_id | INT FK → categories.id | field_doctor_services |
|
||||||
|
|
||||||
|
## جدول: clinic_insurances (ManyToMany)
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| clinic_id | INT FK → clinics.id CASCADE | |
|
||||||
|
| category_id | INT FK → categories.id | field_insurance (insurance_type یا supplementary_insurance bundle) |
|
||||||
|
|
||||||
|
## جدول: clinic_images (تصاویر اضافی)
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT AUTO_INCREMENT PK | | |
|
||||||
|
| clinic_id | INT FK → clinics.id CASCADE | | |
|
||||||
|
| file_id | INT FK → files.id | field_image_clinic | حداکثر ۵ تصویر (cardinality=5) |
|
||||||
|
| sort_order | INT DEFAULT 0 | delta | ترتیب |
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_clinics_owner ON clinics(user_id);
|
||||||
|
CREATE INDEX idx_clinics_city ON clinics(city_id);
|
||||||
|
CREATE INDEX idx_clinics_state ON clinics(state_id);
|
||||||
|
CREATE INDEX idx_clinics_representation ON clinics(representation_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- `field_agent` در Drupal به user اشاره دارد (cardinality=-1 نامحدود) — در Symfony سادهتر شده و فقط یک representation دارد
|
||||||
|
- `field_working_days` متن آزاد است، مثل 'شنبه تا سهشنبه ۸ تا ۱۲'، نه فرمت ساختاریافته
|
||||||
|
- `field_telephone` نه `phone` — اسم فیلد از Drupal config گرفته شده
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۰۶: ماژول کلینیک
|
||||||
|
|
||||||
|
## لیست دکترهای کلینیک
|
||||||
|
endpoint `GET /api/v1/clinic/doctor-list/{uuid}` لیست دکترهایی که
|
||||||
|
به این کلینیک تعلق دارند را برمیگرداند. اطلاعات دکتر شامل:
|
||||||
|
- نام، تخصص، تصویر، میانگین امتیاز
|
||||||
|
|
||||||
|
## آپلود دو نوع تصویر
|
||||||
|
کلینیک دو تصویر دارد:
|
||||||
|
- `image`: تصویر اصلی/배너 کلینیک (حداکثر ۵MB)
|
||||||
|
- `logo`: لوگوی کلینیک (حداکثر ۲MB، ترجیحاً مربعی)
|
||||||
|
|
||||||
|
هر دو در مسیر `public/uploads/clinics/` ذخیره میشوند.
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
POST /api/v1/clinic → ROLE_ADMIN
|
||||||
|
GET /api/v1/clinic/{uuid} → عمومی
|
||||||
|
PATCH /api/v1/clinic/{uuid} → owner یا ROLE_ADMIN
|
||||||
|
GET /api/v1/clinics → عمومی
|
||||||
|
GET clinic/doctor-list → عمومی
|
||||||
|
POST clinic/image → owner یا ROLE_ADMIN
|
||||||
|
POST clinic/logo → owner یا ROLE_ADMIN
|
||||||
|
```
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# تسک ۰۶: ماژول کلینیک
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
پیادهسازی مدیریت کلینیکها، لیست کلینیکها، لیست دکترهای هر کلینیک
|
||||||
|
و آپلود تصویر و لوگوی کلینیک.
|
||||||
|
|
||||||
|
## Endpoint ها (واقعی از Drupal)
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/clinic` | ایجاد کلینیک | بله |
|
||||||
|
| GET | `/api/v1/clinic/{uuid}` | دریافت اطلاعات کامل کلینیک | خیر |
|
||||||
|
| PATCH | `/api/v1/clinic/{uuid}` | ویرایش کلینیک | بله (Owner/Admin) |
|
||||||
|
| GET | `/api/v1/clinics` | لیست کلینیکها با فیلتر | خیر |
|
||||||
|
| GET | `/api/v1/clinic/doctor-list/{clinic_uuid}` | لیست دکترهای کلینیک | خیر |
|
||||||
|
| POST | `/file/upload/clinic_pro/clinic/field_image_clinic` | آپلود تصویر گالری کلینیک | بله |
|
||||||
|
| POST | `/file/upload/clinic_pro/clinic/field_clinic_logo` | آپلود لوگوی کلینیک | بله |
|
||||||
|
|
||||||
|
> **⚠ مسیر آپلود واقعی:** `/file/upload/clinic_pro/clinic/field_image_clinic` (نه `/api/v1/clinic/image`)
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۰۸ (Categories)
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۶ تا ۸ ساعت
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فیلترهای GET /api/v1/clinics
|
||||||
|
|
||||||
|
| پارامتر | نوع | الزامی | مثال |
|
||||||
|
|---------|-----|--------|------|
|
||||||
|
| `state` | string | خیر | `13` |
|
||||||
|
| `city` | string | خیر | `32` |
|
||||||
|
| `specialty` | string | خیر | `511` |
|
||||||
|
| `page` | string | بله | `1` |
|
||||||
|
| `limit` | string | بله | `10` |
|
||||||
|
| `sort` | string | خیر | `DESC` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نمونه واقعی Response — GET /api/v1/clinic/{uuid}
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "23",
|
||||||
|
"uuid": "e4550163-5a88-4f67-b07e-cd6063738598",
|
||||||
|
"title": "clinic 1",
|
||||||
|
"images_clinic": [
|
||||||
|
{
|
||||||
|
"url": "https://domain.com/sites/default/files/2025-11/image.png",
|
||||||
|
"fid": "91",
|
||||||
|
"filename": "image.png",
|
||||||
|
"filemime": "image/png",
|
||||||
|
"filesize": 34314
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clinic_logo": [
|
||||||
|
{
|
||||||
|
"url": "https://domain.com/sites/default/files/clinics/logo/2025-11/logo.png",
|
||||||
|
"fid": "96",
|
||||||
|
"filename": "logo.png",
|
||||||
|
"filemime": "image/png",
|
||||||
|
"filesize": 39281
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"phone_number": "۰۶۱-۳۳۹۱۶۵۸۹",
|
||||||
|
"caption": "توضیحات درباره کلینیک...",
|
||||||
|
"list_bime": [
|
||||||
|
{
|
||||||
|
"uuid": "...",
|
||||||
|
"id": "1559",
|
||||||
|
"name": "بیمه آتیه سازان حافظ",
|
||||||
|
"logo": [{ "url": "...", "fid": "5", "filename": "hafez-insurance.png", "filemime": "image/png", "filesize": 16100 }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"specialties": [
|
||||||
|
{ "uuid": "...", "id": "610", "name": "روماتولوژی", "parent": "602" }
|
||||||
|
],
|
||||||
|
"services": [
|
||||||
|
{ "uuid": "...", "id": "1602", "name": "ویزیت تخصصی" }
|
||||||
|
],
|
||||||
|
"clinic_specialty": [
|
||||||
|
{ "uuid": "...", "id": "610", "name": "روماتولوژی", "parent": "602" }
|
||||||
|
],
|
||||||
|
"doctors": 3,
|
||||||
|
"doctor_list": null,
|
||||||
|
"city": [{ "uuid": "...", "id": "130", "name": "بندرعباس", "parent": "29" }],
|
||||||
|
"state": [{ "uuid": "...", "id": "29", "name": "هرمزگان" }],
|
||||||
|
"location": "بندرعباس: رسالت شمالی- میدان صادقیه",
|
||||||
|
"map": { "latitude": "27.200632975404", "longitude": "56.356043815613" },
|
||||||
|
"24_7": true,
|
||||||
|
"field_working_days": "شنبه تا سه شنبه ساعت ۱۲:۲۰"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **⚠ نکات فیلدهای واقعی Response:**
|
||||||
|
> - **`phone_number`** (نه `telephone`!) — شماره تماس
|
||||||
|
> - **`caption`** (نه `info`!) — توضیحات کلینیک
|
||||||
|
> - **`images_clinic`** (نه `images`!) — تصاویر گالری با url/fid/filename/filemime/filesize
|
||||||
|
> - **`clinic_logo`** (نه `logo`!) — لوگو با url/fid/filename/filemime/filesize
|
||||||
|
> - **`list_bime`** (نه `insurances`!) — بیمههای کلینیک، هر آیتم دارای `logo` نیز هست
|
||||||
|
> - **`field_working_days`** — روزهای کاری (string آزاد)
|
||||||
|
> - **`24_7`** — boolean
|
||||||
|
> - **`doctors`** — تعداد دکترها (integer)
|
||||||
|
> - **`services`** و **`clinic_specialty`** هر دو در response هستند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فیلدهای PATCH /api/v1/clinic/{uuid}
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "نام کلینیک",
|
||||||
|
"address": "آدرس",
|
||||||
|
"telephone": "شماره تلفن",
|
||||||
|
"latitude": "27.2",
|
||||||
|
"longitude": "56.3",
|
||||||
|
"insurance": [300, 301],
|
||||||
|
"info": "توضیحات",
|
||||||
|
"doctor_services": [575, 576],
|
||||||
|
"working_days": "شنبه تا سهشنبه",
|
||||||
|
"24_7": 1,
|
||||||
|
"image_clinic": [91, 92],
|
||||||
|
"clinic_logo": [96]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## فیلدهای POST /api/v1/clinic (ایجاد)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "نام کلینیک",
|
||||||
|
"state": [1],
|
||||||
|
"city": [32],
|
||||||
|
"address": "آدرس",
|
||||||
|
"telephone": "شماره تلفن",
|
||||||
|
"latitude": "50.21",
|
||||||
|
"longitude": "57.212",
|
||||||
|
"insurance": [300, 301],
|
||||||
|
"info": "توضیحات",
|
||||||
|
"doctor_services": [575, 576],
|
||||||
|
"working_days": "شنبه تا سهشنبه",
|
||||||
|
"24_7": 1,
|
||||||
|
"image_clinic": [19],
|
||||||
|
"clinic_logo": [20]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## آپلود تصویر/لوگوی کلینیک
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /file/upload/clinic_pro/clinic/field_image_clinic
|
||||||
|
POST /file/upload/clinic_pro/clinic/field_clinic_logo
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
Content-Type: application/octet-stream
|
||||||
|
Content-Disposition: file; filename="clinic.png"
|
||||||
|
X-CSRF-Token: {token}
|
||||||
|
Authorization: Bearer {token}
|
||||||
|
Body: binary file content
|
||||||
|
Response → { fid, ... } که در image_clinic یا clinic_logo استفاده میشود
|
||||||
|
```
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# معماری — تسک ۰۸: ماژول دستهبندیها
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Category/
|
||||||
|
├── Controller/
|
||||||
|
│ └── CategoryController.php ← همه endpoint ها
|
||||||
|
├── Service/
|
||||||
|
│ └── CategoryService.php
|
||||||
|
├── Repository/
|
||||||
|
│ └── CategoryRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ └── Category.php
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ ├── CreateCategoryRequest.php
|
||||||
|
│ │ └── UpdateCategoryRequest.php
|
||||||
|
│ └── Response/
|
||||||
|
│ └── CategoryResponse.php
|
||||||
|
└── DataFixtures/
|
||||||
|
└── CategoryFixtures.php ← دادههای اولیه (استان، شهر، تخصص، ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Category
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'categories')]
|
||||||
|
class Category
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 200)]
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $code; // کد انگلیسی برای فیلتر
|
||||||
|
|
||||||
|
// نوع دسته: tag, supplementary_insurance, insurance_type,
|
||||||
|
// state, city, specially_doctor, doctor_services
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
private string $type;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: self::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true)]
|
||||||
|
private ?Category $parent; // برای رابطه استان-شهر
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', default: 0)]
|
||||||
|
private int $sortOrder = 0;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routing نمونه
|
||||||
|
```php
|
||||||
|
// GET /api/v1/categorys/{type}
|
||||||
|
#[Route('/api/v1/categorys/{type}', methods: ['GET'])]
|
||||||
|
public function listByType(string $type): Response
|
||||||
|
{
|
||||||
|
$allowed = ['tag', 'supplementary_insurance', 'insurance_type',
|
||||||
|
'state', 'city', 'specially_doctor', 'doctor_services'];
|
||||||
|
if (!in_array($type, $allowed)) {
|
||||||
|
return $this->notFound();
|
||||||
|
}
|
||||||
|
return $this->json($this->categoryService->findByType($type));
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# پایگاه داده — تسک ۰۸: ماژول دستهبندیها
|
||||||
|
|
||||||
|
## ساختار واقعی از DB backup
|
||||||
|
|
||||||
|
جدول `category` یک entity با چند bundle است.
|
||||||
|
بیشتر دادهها (استانها، شهرها، تخصصها) در INSERTهای DB backup موجودند.
|
||||||
|
|
||||||
|
## جدول: categories
|
||||||
|
_(entity_type=category — از DB backup تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| bundle | VARCHAR(32) NOT NULL | bundle | نوع دستهبندی |
|
||||||
|
| label | VARCHAR(255) NULL | label | نام (base field) |
|
||||||
|
| status | TINYINT(1) DEFAULT 1 | status | فعال/غیرفعال |
|
||||||
|
| parent_id | INT FK → categories.id NULL | field_parent | والد (استان→شهر / تخصص والد) |
|
||||||
|
| weight | INT DEFAULT 0 | field_weight | ترتیب نمایش |
|
||||||
|
| logo_id | INT FK → files.id NULL | field_logo | تصویر/آیکون (برای بیمهها) |
|
||||||
|
| title | VARCHAR(255) NULL | field_title | عنوان جایگزین |
|
||||||
|
| representation_id | INT FK → representations.id NULL | field_representation | نماینده مرتبط (برای city bundle) |
|
||||||
|
|
||||||
|
## فیلدهای اختصاصی bundle=city
|
||||||
|
_(فقط برای city bundle — از config تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| contact_phone | VARCHAR(255) NULL | field_contactphone | تلفن |
|
||||||
|
| email | VARCHAR(255) NULL | field_email | ایمیل |
|
||||||
|
| description | TEXT NULL | field_description | توضیحات |
|
||||||
|
| slogan | VARCHAR(255) NULL | field_slogan | شعار |
|
||||||
|
| domain | VARCHAR(255) NULL | field_domain | دامنه اختصاصی شهر |
|
||||||
|
| keywords | VARCHAR(255) NULL | field_keywords | کلیدواژه SEO |
|
||||||
|
| footer_description | TEXT NULL | field_footerdescription | توضیحات footer |
|
||||||
|
| footer_disclaimer | TEXT NULL | field_footerdisclaimer | سلب مسئولیت |
|
||||||
|
| social_media | LONGTEXT NULL | field_socialmedia | شبکههای اجتماعی (JSON) |
|
||||||
|
|
||||||
|
## مقادیر مجاز bundle
|
||||||
|
```
|
||||||
|
state → استان (31 استان ایران — داده در DB موجود)
|
||||||
|
city → شهر (parent_id = state_id)
|
||||||
|
specially_doctor → تخصص پزشکی
|
||||||
|
doctor_services → سرویسهای پزشکی
|
||||||
|
insurance_type → نوع بیمه پایه
|
||||||
|
supplementary_insurance → بیمه تکمیلی
|
||||||
|
tag → تگ بلاگ
|
||||||
|
```
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_categories_bundle ON categories(bundle);
|
||||||
|
CREATE INDEX idx_categories_parent ON categories(parent_id);
|
||||||
|
CREATE INDEX idx_categories_status ON categories(status, bundle);
|
||||||
|
```
|
||||||
|
|
||||||
|
## دادههای موجود در DB backup (نمونه)
|
||||||
|
```
|
||||||
|
استانها: id=1..100 (bundle='state') — تمام ۳۱ استان ایران
|
||||||
|
شهرها: id=101..2503 (bundle='city') — شهرهای ایران
|
||||||
|
```
|
||||||
|
دادهها باید از DB backup به Symfony fixtures مهاجرت داده شوند.
|
||||||
|
|
||||||
|
## نکته مهم: city bundle
|
||||||
|
City bundle فیلدهای زیادی دارد که برای نمایش اطلاعات سایت نماینده استفاده میشوند:
|
||||||
|
- `field_representation` → لینک به نماینده (multi-tenant)
|
||||||
|
- `field_domain` → دامنه اختصاصی شهر
|
||||||
|
- `field_slogan`, `field_keywords` → SEO
|
||||||
|
این فیلدها در Symfony در جدول جداگانه `city_settings` یا در همان categories با nullable columns نگهداشته میشوند.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۰۸: ماژول دستهبندیها
|
||||||
|
|
||||||
|
## کشینگ
|
||||||
|
لیستهای Lookup (استان، شهر، تخصص) بهندرت تغییر میکنند.
|
||||||
|
→ نتایج را با Symfony Cache (Redis) کش کن:
|
||||||
|
```php
|
||||||
|
public function findByType(string $type): array
|
||||||
|
{
|
||||||
|
return $this->cache->get("categories_{$type}", function (ItemInterface $item) use ($type) {
|
||||||
|
$item->expiresAfter(3600); // 1 ساعت
|
||||||
|
return $this->repository->findBy(['type' => $type], ['sortOrder' => 'ASC']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
→ هنگام ایجاد/ویرایش/حذف category، کش مربوط را invalidate کن.
|
||||||
|
|
||||||
|
## فیلتر شهر براساس استان
|
||||||
|
برای لیست شهرها، پارامتر `state_id` اختیاری است:
|
||||||
|
```
|
||||||
|
GET /api/v1/categorys/city?state_id=5
|
||||||
|
```
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
GET /api/v1/categorys/* → عمومی (بدون auth)
|
||||||
|
POST /api/v1/category → ROLE_ADMIN
|
||||||
|
PATCH /api/v1/category/{id} → ROLE_ADMIN
|
||||||
|
DELETE /api/v1/category/{id} → ROLE_ADMIN
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکته نام endpoint
|
||||||
|
در Drupal از `categorys` (اشتباه گرامری) استفاده شده.
|
||||||
|
در Symfony همان مسیر را حفظ کن تا کلاینت تغییر نکند.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# تسک ۰۸: ماژول دستهبندیها و Lookup ها
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
پیادهسازی لیستهای ثابت (lookup) مثل استانها، شهرها، تخصصهای پزشکی،
|
||||||
|
بیمهها، تگها و مدیریت دستهبندیها.
|
||||||
|
|
||||||
|
## Endpoint ها
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| GET | `/api/v1/categorys/tag` | لیست تگها | خیر |
|
||||||
|
| GET | `/api/v1/categorys/supplementary_insurance` | بیمههای تکمیلی | خیر |
|
||||||
|
| GET | `/api/v1/categorys/insurance_type` | نوع بیمه پایه | خیر |
|
||||||
|
| GET | `/api/v1/categorys/state` | لیست استانها | خیر |
|
||||||
|
| GET | `/api/v1/categorys/city` | لیست شهرها | خیر |
|
||||||
|
| GET | `/api/v1/categorys/specially_doctor` | تخصصهای پزشکی | خیر |
|
||||||
|
| GET | `/api/v1/categorys/doctor_services` | سرویسهای پزشکی | خیر |
|
||||||
|
| POST | `/api/v1/category` | ایجاد دستهبندی | بله (Admin) |
|
||||||
|
| PATCH | `/api/v1/category/{id}` | ویرایش دستهبندی | بله (Admin) |
|
||||||
|
| DELETE | `/api/v1/category/{id}` | حذف دستهبندی | بله (Admin) |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱ و ۰۲
|
||||||
|
|
||||||
|
## نکته مهم
|
||||||
|
این تسک باید **قبل از تسکهای ۰۵، ۰۶** انجام شود چون
|
||||||
|
تسکهای دکتر و کلینیک به categories وابستهاند.
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۴ تا ۵ ساعت
|
||||||
|
|
||||||
|
## نمونه Response
|
||||||
|
|
||||||
|
### GET /api/v1/categorys/state
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": 1, "name": "تهران", "code": "tehran" },
|
||||||
|
{ "id": 2, "name": "اصفهان", "code": "isfahan" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/v1/categorys/specially_doctor
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": 1, "name": "قلب و عروق", "code": "cardiology" },
|
||||||
|
{ "id": 2, "name": "مغز و اعصاب", "code": "neurology" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# معماری — تسک ۰۹: ماژول تنظیمات نوبتدهی
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/AppointmentSettings/
|
||||||
|
├── Controller/
|
||||||
|
│ ├── WeeklyScheduleController.php
|
||||||
|
│ ├── DateOverrideController.php
|
||||||
|
│ └── HolidayController.php
|
||||||
|
├── Service/
|
||||||
|
│ ├── WeeklyScheduleService.php
|
||||||
|
│ ├── DateOverrideService.php
|
||||||
|
│ └── HolidayService.php
|
||||||
|
├── Repository/
|
||||||
|
│ ├── WeeklyScheduleRepository.php
|
||||||
|
│ ├── DateOverrideRepository.php
|
||||||
|
│ └── HolidayRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ ├── WeeklySchedule.php
|
||||||
|
│ ├── DateOverride.php
|
||||||
|
│ └── Holiday.php
|
||||||
|
└── DTO/
|
||||||
|
├── Request/
|
||||||
|
│ ├── CreateWeeklyScheduleRequest.php
|
||||||
|
│ ├── CreateDateOverrideRequest.php
|
||||||
|
│ └── CreateHolidayRequest.php
|
||||||
|
└── Response/
|
||||||
|
├── WeeklyScheduleResponse.php
|
||||||
|
└── DateOverrideResponse.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: WeeklySchedule
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'weekly_schedules')]
|
||||||
|
class WeeklySchedule
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\OneToOne(targetEntity: Doctor::class)]
|
||||||
|
private Doctor $doctor;
|
||||||
|
|
||||||
|
// هر روز هفته یک JSON: {active, slots: [{start, end, duration}]}
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $saturday = ['active' => false, 'slots' => []];
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $sunday = ['active' => false, 'slots' => []];
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $monday = ['active' => false, 'slots' => []];
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $tuesday = ['active' => false, 'slots' => []];
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $wednesday = ['active' => false, 'slots' => []];
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $thursday = ['active' => false, 'slots' => []];
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $friday = ['active' => false, 'slots' => []];
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: DateOverride
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'date_overrides')]
|
||||||
|
class DateOverride
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||||
|
private Doctor $doctor;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date')]
|
||||||
|
private \DateTimeInterface $date;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', default: false)]
|
||||||
|
private bool $active;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 200, nullable: true)]
|
||||||
|
private ?string $reason;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $customSlots; // [{start, end, duration}]
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Holiday
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'holidays')]
|
||||||
|
class Holiday
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||||
|
private Doctor $doctor;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date')]
|
||||||
|
private \DateTimeInterface $startDate;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date')]
|
||||||
|
private \DateTimeInterface $endDate;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 200, nullable: true)]
|
||||||
|
private ?string $reason;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# پایگاه داده — تسک ۰۹: ماژول تنظیمات نوبتدهی
|
||||||
|
|
||||||
|
## مهم: ساختار واقعی field_setting از DB backup
|
||||||
|
|
||||||
|
**تفاوت اساسی با طراحی اولیه:**
|
||||||
|
- یک فیلد JSON به نام `field_setting` کل برنامه هفتگی را ذخیره میکند
|
||||||
|
- ساختار: **آرایه ۷ المان** (ایندکس 0=شنبه تا 6=جمعه)
|
||||||
|
- هر روز دو نوبت **صبح** و **عصر** دارد (نه slotهای آرایهای)
|
||||||
|
|
||||||
|
## جدول: weekly_schedules
|
||||||
|
_(entity_type=appointment_settings, bundle=weekly_schedule)_
|
||||||
|
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||||
|
| doctor_id | INT FK → doctors.id UNIQUE | یک رکورد بهازای هر دکتر |
|
||||||
|
| setting | LONGTEXT NOT NULL | JSON برنامه کامل هفتگی |
|
||||||
|
| created_at | INT NOT NULL | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | Unix timestamp |
|
||||||
|
|
||||||
|
## ساختار واقعی JSON فیلد `setting` (از DB backup)
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"morning": {
|
||||||
|
"active": 1,
|
||||||
|
"location_id": 48,
|
||||||
|
"start_time": "08:00",
|
||||||
|
"end_time": "12:00",
|
||||||
|
"patient_limit": 10,
|
||||||
|
"duration_per_patient": 15,
|
||||||
|
"has_rest": true,
|
||||||
|
"rest_interval": 60,
|
||||||
|
"time_to_rest": 10
|
||||||
|
},
|
||||||
|
"evening": {
|
||||||
|
"active": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"morning": { "active": 0 },
|
||||||
|
"evening": {
|
||||||
|
"active": 1,
|
||||||
|
"location_id": 49,
|
||||||
|
"start_time": "15:00",
|
||||||
|
"end_time": "18:00",
|
||||||
|
"patient_limit": 8,
|
||||||
|
"duration_per_patient": 20,
|
||||||
|
"has_rest": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
ایندکس روزها:
|
||||||
|
| ایندکس | روز |
|
||||||
|
|--------|-----|
|
||||||
|
| 0 | شنبه |
|
||||||
|
| 1 | یکشنبه |
|
||||||
|
| 2 | دوشنبه |
|
||||||
|
| 3 | سهشنبه |
|
||||||
|
| 4 | چهارشنبه |
|
||||||
|
| 5 | پنجشنبه |
|
||||||
|
| 6 | جمعه |
|
||||||
|
|
||||||
|
فیلدهای هر session (morning/evening):
|
||||||
|
| فیلد | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| active | 0/1 | آیا این نوبت فعال است |
|
||||||
|
| location_id | int | ID آدرس مطب (→ doctor_addresses) |
|
||||||
|
| start_time | "HH:MM" | ساعت شروع |
|
||||||
|
| end_time | "HH:MM" | ساعت پایان |
|
||||||
|
| patient_limit | int | حداکثر تعداد بیمار |
|
||||||
|
| duration_per_patient | int (دقیقه) | مدت هر ویزیت |
|
||||||
|
| has_rest | boolean | آیا استراحت دارد |
|
||||||
|
| rest_interval | int (دقیقه) | فاصله استراحت |
|
||||||
|
| time_to_rest | int (دقیقه) | مدت استراحت |
|
||||||
|
|
||||||
|
## جدول: date_overrides
|
||||||
|
_(entity_type=appointment_settings, bundle=date_override)_
|
||||||
|
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||||
|
| doctor_id | INT FK → doctors.id | دکتر |
|
||||||
|
| date | INT NOT NULL | تاریخ (Unix timestamp) — field_date |
|
||||||
|
| active | TINYINT(1) DEFAULT 0 | آیا کار میکند — field_active |
|
||||||
|
| setting | LONGTEXT NULL | JSON اسلاتهای سفارشی (همان ساختار field_setting) |
|
||||||
|
| created_at | INT NOT NULL | |
|
||||||
|
| updated_at | INT NOT NULL | |
|
||||||
|
|
||||||
|
## جدول: holidays
|
||||||
|
_(entity_type=appointment_settings, bundle=holidays)_
|
||||||
|
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||||
|
| doctor_id | INT FK → doctors.id | دکتر |
|
||||||
|
| start_date | INT NOT NULL | تاریخ شروع (Unix timestamp) |
|
||||||
|
| end_date | INT NOT NULL | تاریخ پایان (Unix timestamp) |
|
||||||
|
| active | TINYINT(1) DEFAULT 1 | field_active |
|
||||||
|
| created_at | INT NOT NULL | |
|
||||||
|
| updated_at | INT NOT NULL | |
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_weekly_schedules_doctor ON weekly_schedules(doctor_id);
|
||||||
|
CREATE INDEX idx_date_overrides_doctor_date ON date_overrides(doctor_id, date);
|
||||||
|
CREATE INDEX idx_holidays_doctor_range ON holidays(doctor_id, start_date, end_date);
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- **GET /appointment-settings/{uuid}** — uuid دکتر است، نه uuid schedule
|
||||||
|
- `setting[0]` ایندکس 0=شنبه تا 6=جمعه (هفته ایرانی)
|
||||||
|
- هر روز دقیقاً ۲ نوبت (morning و evening) دارد
|
||||||
|
- session غیرفعال فقط `{"active": 0}` است، بقیه فیلدها ندارد
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۰۹: ماژول تنظیمات نوبتدهی
|
||||||
|
|
||||||
|
## اولویتبندی تنظیمات (از Manual — بخش ۲.۸.۴)
|
||||||
|
هنگام محاسبه اسلاتهای خالی (تسک ۱۰):
|
||||||
|
```
|
||||||
|
1. DateOverride (بالاترین) → اگر Override فعال برای این تاریخ وجود دارد، تعطیلی نادیده گرفته میشود
|
||||||
|
2. Holiday → اگر تاریخ تعطیل است AND override ندارد → روز بسته است
|
||||||
|
3. WeeklySchedule (پایین) → در صورت نبود override و تعطیلی → برنامه هفتگی
|
||||||
|
```
|
||||||
|
|
||||||
|
## UUID در URL endpoint لیست override ها
|
||||||
|
مسیر `GET /api/v1/appointment-settings/date-override/list/{uuid}`
|
||||||
|
→ این `uuid` برابر است با UUID دکتر (نه DateOverride)
|
||||||
|
|
||||||
|
## ساختار واقعی هر session در weekly schedule (از Manual و API request)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"active": 1,
|
||||||
|
"number_of_turns": 10, ← تعداد نوبت (نه patient_limit!)
|
||||||
|
"turn_time": 10, ← مدت هر نوبت به دقیقه (نه duration_per_patient!)
|
||||||
|
"location": { "id": 48 }, ← آدرس مطب (object، نه فقط ID)
|
||||||
|
"start_time": "10:00",
|
||||||
|
"end_time": "13:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ساختار کامل weekly schedule (7 روز — از "0"=شنبه تا "6"=جمعه):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"0": {
|
||||||
|
"morning": { "active": 1, "number_of_turns": 10, "turn_time": 10, "location": {"id": 48}, "start_time": "10:00", "end_time": "13:00" },
|
||||||
|
"evening": { "active": 0 }
|
||||||
|
},
|
||||||
|
"1": { "morning": {"active": 0}, "evening": { "active": 1, ... } },
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**مهم:** در DB backup، فیلدهای `patient_limit` و `duration_per_patient` استفاده شده بود.
|
||||||
|
در API (کلاینت) از `number_of_turns` و `turn_time` استفاده میشود.
|
||||||
|
در Symfony باید هر دو نام را پشتیبانی کنی یا از نامهای API استفاده کنی.
|
||||||
|
|
||||||
|
## مهم: ذخیرهسازی field_setting (از کد واقعی)
|
||||||
|
```php
|
||||||
|
// در Drupal:
|
||||||
|
$normalized['field_setting'] = json_encode($data['setting'][0]);
|
||||||
|
// یعنی اولین المان آرایهای که فرانت میفرستد ذخیره میشود
|
||||||
|
|
||||||
|
// در Symfony هم همین رویکرد:
|
||||||
|
$weeklySchedule->setSetting(json_encode($request->getSetting()[0]));
|
||||||
|
```
|
||||||
|
|
||||||
|
## GET از UUID دکتر (نه UUID schedule)
|
||||||
|
```php
|
||||||
|
// weeklyScheduleService.get($uuid) در Drupal:
|
||||||
|
// 1. ابتدا دکتر با این uuid را پیدا کن → $doctorEntity
|
||||||
|
// 2. سپس schedule با field_doctor_id = $doctorId را پیدا کن
|
||||||
|
|
||||||
|
// در Symfony:
|
||||||
|
$doctor = $this->doctorRepo->findByUuid($uuid);
|
||||||
|
$schedule = $this->scheduleRepo->findByDoctor($doctor);
|
||||||
|
```
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
تمام endpoint های این ماژول نیاز به احراز هویت دارند:
|
||||||
|
```
|
||||||
|
POST/PATCH/DELETE → دکتر مرتبط (owner) یا ROLE_ADMIN
|
||||||
|
GET → دکتر مرتبط یا ROLE_ADMIN یا منشی دکتر
|
||||||
|
```
|
||||||
|
|
||||||
|
## user_flow
|
||||||
|
جریان کامل در فایل جداگانه user_flow.md توضیح داده شده است.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# تسک ۰۹: ماژول تنظیمات نوبتدهی
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
پیادهسازی سیستم تنظیمات نوبتدهی دکتر شامل برنامه هفتگی،
|
||||||
|
override روزهای خاص و تعطیلات.
|
||||||
|
|
||||||
|
## Endpoint ها
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/appointment-settings/weekly-schedule` | ایجاد برنامه هفتگی | بله (Doctor) |
|
||||||
|
| PATCH | `/api/v1/appointment-settings/weekly-schedule/{uuid}` | ویرایش برنامه | بله |
|
||||||
|
| GET | `/api/v1/appointment-settings/weekly-schedule/{uuid}` | دریافت برنامه | بله |
|
||||||
|
| DELETE | `/api/v1/booking-setting/{uuid}` | حذف تنظیمات | بله |
|
||||||
|
| GET | `/api/v1/appointment-settings/date-override/list/{uuid}` | لیست override ها | بله |
|
||||||
|
| POST | `/api/v1/appointment-settings/date-override` | ایجاد override | بله (Doctor) |
|
||||||
|
| PATCH | `/api/v1/appointment-settings/date-override/{uuid}` | ویرایش override | بله |
|
||||||
|
| DELETE | `/api/v1/appointment-settings/date-override/{uuid}` | حذف override | بله |
|
||||||
|
| GET | `/api/v1/appointment-settings/date-override/{uuid}` | دریافت override | بله |
|
||||||
|
| POST | `/api/v1/appointment-settings/holidays` | ثبت تعطیلات | بله (Doctor) |
|
||||||
|
| PATCH | `/api/v1/appointment-settings/holidays/{uuid}` | ویرایش تعطیلات | بله |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱، ۰۲، ۰۵ (Doctor)
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۱۰ تا ۱۲ ساعت
|
||||||
|
|
||||||
|
## نمونه Request
|
||||||
|
|
||||||
|
### POST /api/v1/appointment-settings/weekly-schedule
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"doctor_uuid": "61be915b-...",
|
||||||
|
"schedule": {
|
||||||
|
"saturday": { "active": true, "slots": [{"start": "09:00", "end": "13:00", "duration": 30}] },
|
||||||
|
"sunday": { "active": true, "slots": [{"start": "09:00", "end": "13:00", "duration": 30}] },
|
||||||
|
"monday": { "active": false, "slots": [] },
|
||||||
|
"tuesday": { "active": true, "slots": [{"start": "14:00", "end": "18:00", "duration": 20}] },
|
||||||
|
"wednesday": { "active": false, "slots": [] },
|
||||||
|
"thursday": { "active": true, "slots": [{"start": "09:00", "end": "12:00", "duration": 30}] },
|
||||||
|
"friday": { "active": false, "slots": [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/appointment-settings/date-override
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"doctor_uuid": "...",
|
||||||
|
"date": "2024-03-20",
|
||||||
|
"active": false,
|
||||||
|
"reason": "مرخصی",
|
||||||
|
"custom_slots": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/v1/appointment-settings/holidays
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"doctor_uuid": "...",
|
||||||
|
"start_date": "2024-03-20",
|
||||||
|
"end_date": "2024-03-27",
|
||||||
|
"reason": "نوروز"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# جریان کاربری — تسک ۰۹: تنظیمات نوبتدهی
|
||||||
|
|
||||||
|
## جریان تنظیم اولیه نوبتدهی توسط دکتر
|
||||||
|
|
||||||
|
```
|
||||||
|
دکتر وارد پنل میشود
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/v1/appointment-settings/weekly-schedule
|
||||||
|
{ doctor_uuid, schedule: { saturday: {...}, sunday: {...}, ... } }
|
||||||
|
│
|
||||||
|
└─► ذخیره برنامه هفتگی پایه
|
||||||
|
```
|
||||||
|
|
||||||
|
## جریان ثبت مرخصی یا تعطیلات
|
||||||
|
|
||||||
|
```
|
||||||
|
دکتر تعطیلات را ثبت میکند
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/v1/appointment-settings/holidays
|
||||||
|
{ doctor_uuid, start_date, end_date, reason }
|
||||||
|
│
|
||||||
|
└─► در بازه تعطیلات، هیچ نوبتی نمیتوان گرفت
|
||||||
|
```
|
||||||
|
|
||||||
|
## جریان override یک روز خاص
|
||||||
|
|
||||||
|
```
|
||||||
|
دکتر میخواهد یک روز خاص را سفارشی کند
|
||||||
|
│
|
||||||
|
├─► غیرفعال کردن یک روز:
|
||||||
|
│ POST /date-override { date: "2024-03-15", active: false }
|
||||||
|
│
|
||||||
|
└─► اسلات سفارشی برای یک روز:
|
||||||
|
POST /date-override {
|
||||||
|
date: "2024-03-15",
|
||||||
|
active: true,
|
||||||
|
custom_slots: [{ start: "10:00", end: "12:00", duration: 20 }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## الگوریتم محاسبه اسلاتهای خالی (تسک ۱۰ از این استفاده میکند)
|
||||||
|
|
||||||
|
```
|
||||||
|
برای تاریخ درخواستشده:
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
آیا در بازه Holiday است؟
|
||||||
|
بله → نوبت موجود نیست
|
||||||
|
خیر │
|
||||||
|
▼
|
||||||
|
آیا DateOverride برای این تاریخ وجود دارد؟
|
||||||
|
بله → active=false: نوبت موجود نیست
|
||||||
|
active=true: از custom_slots استفاده کن
|
||||||
|
خیر │
|
||||||
|
▼
|
||||||
|
از WeeklySchedule روز هفته مربوطه استفاده کن
|
||||||
|
active=false: نوبت موجود نیست
|
||||||
|
active=true: اسلاتهای slots را محاسبه کن
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
اسلاتهای رزروشده را حذف کن (از جدول appointments)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
لیست اسلاتهای خالی را برگردان
|
||||||
|
```
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# معماری — تسک ۱۰: ماژول نوبتدهی
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Appointment/
|
||||||
|
├── Controller/
|
||||||
|
│ └── AppointmentController.php
|
||||||
|
├── Service/
|
||||||
|
│ ├── AppointmentService.php
|
||||||
|
│ └── SlotCalculatorService.php ← محاسبه اسلاتهای خالی
|
||||||
|
├── Repository/
|
||||||
|
│ └── AppointmentRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ └── Appointment.php
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ └── CreateAppointmentRequest.php
|
||||||
|
│ └── Response/
|
||||||
|
│ ├── AppointmentResponse.php
|
||||||
|
│ └── SlotResponse.php
|
||||||
|
└── Voter/
|
||||||
|
└── AppointmentVoter.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Appointment
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'appointments')]
|
||||||
|
class Appointment
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
private User $patient;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||||
|
private Doctor $doctor;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date')]
|
||||||
|
private \DateTimeInterface $appointmentDate;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 10)]
|
||||||
|
private string $appointmentTime; // HH:MM
|
||||||
|
|
||||||
|
// pending, confirmed, cancelled, completed
|
||||||
|
#[ORM\Column(length: 20, default: 'pending')]
|
||||||
|
private string $status;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 30, nullable: true)]
|
||||||
|
private ?string $insuranceType;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
|
private ?string $notes;
|
||||||
|
|
||||||
|
#[ORM\OneToOne(targetEntity: Payment::class, mappedBy: 'appointment')]
|
||||||
|
private ?Payment $payment;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## SlotCalculatorService
|
||||||
|
این سرویس با استفاده از WeeklySchedule، DateOverride و Holiday
|
||||||
|
اسلاتهای خالی را برای یک دکتر در یک تاریخ مشخص محاسبه میکند:
|
||||||
|
```
|
||||||
|
calculateAvailableSlots(Doctor $doctor, \DateTimeInterface $date): array
|
||||||
|
```
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# پایگاه داده — تسک ۱۰: ماژول نوبتدهی
|
||||||
|
|
||||||
|
## جدول: appointments
|
||||||
|
_(entity_type=appointment — از DB backup تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| patient_id | INT FK → users.id NOT NULL | uid | بیمار (owner) |
|
||||||
|
| doctor_id | INT FK → doctors.id NOT NULL | field_doctor_id | entity ref → clinic_pro |
|
||||||
|
| address_id | INT FK → doctor_addresses.id NULL | field_address | entity ref → clinic_pro |
|
||||||
|
| representation_id | INT FK → representations.id NULL | field_representation | entity ref → clinic_pro |
|
||||||
|
| start_time | INT NOT NULL | field_start_time | Unix timestamp (Asia/Tehran) |
|
||||||
|
| end_time | INT NOT NULL | field_end_time | Unix timestamp |
|
||||||
|
| slot | LONGTEXT NULL | field_slot | JSON (ساختار زیر) |
|
||||||
|
| status | VARCHAR(40) DEFAULT 'waiting_for_payment' | field_status | وضعیت |
|
||||||
|
| visited_at | INT NULL | field_visited_at | زمان ویزیت (Unix timestamp) |
|
||||||
|
| info | LONGTEXT NULL | field_info | یادداشت |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## ساختار واقعی JSON فیلد `slot` (از DB backup)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"time": "17:00",
|
||||||
|
"status": "available",
|
||||||
|
"start_time_timestamp": 1763472600,
|
||||||
|
"end_time_timestamp": 1763473800,
|
||||||
|
"duration_per_patient": 20,
|
||||||
|
"location_id": 38
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## وضعیتهای کامل (field_status) — از config
|
||||||
|
```
|
||||||
|
waiting_for_payment → پیشفرض — منتظر پرداخت
|
||||||
|
reserved → رزروشده (پرداخت انجام شده)
|
||||||
|
auto_cancel_unpaid → لغو خودکار (عدم پرداخت)
|
||||||
|
cancelled_by_patient → لغو توسط بیمار
|
||||||
|
cancelled_by_doctor → لغو توسط دکتر
|
||||||
|
checked_in → بیمار آمده
|
||||||
|
waiting → در صف انتظار
|
||||||
|
in_progress → در حال ویزیت
|
||||||
|
visited → ویزیت تمام شده
|
||||||
|
no_show → غایب
|
||||||
|
postponed → به تعویق افتاده
|
||||||
|
completed → تکمیل شده
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠️ وضعیتهایی که slot را آزاد میکنند (قابل رزرو مجدد):
|
||||||
|
`auto_cancel_unpaid`, `cancelled_by_patient`, `cancelled_by_doctor`
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_appointments_patient ON appointments(patient_id);
|
||||||
|
CREATE INDEX idx_appointments_doctor ON appointments(doctor_id);
|
||||||
|
CREATE INDEX idx_appointments_doctor_time ON appointments(doctor_id, start_time);
|
||||||
|
CREATE INDEX idx_appointments_status ON appointments(status);
|
||||||
|
CREATE INDEX idx_appointments_representation ON appointments(representation_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## نمونه داده واقعی از DB backup
|
||||||
|
```
|
||||||
|
id=1, uuid='617f78af-...', uid=32, doctor_id=29
|
||||||
|
start_time=1763472600, end_time=1763473800
|
||||||
|
slot: {"time":"17:00","status":"available","start_time_timestamp":1763472600,
|
||||||
|
"end_time_timestamp":1763473800,"duration_per_patient":20,"location_id":38}
|
||||||
|
```
|
||||||
|
|
||||||
|
## روابط
|
||||||
|
- `appointments.patient_id` → `users.id`
|
||||||
|
- `appointments.doctor_id` → `doctors.id` (clinic_pro entity)
|
||||||
|
- `appointments.address_id` → `doctor_addresses.id` (clinic_pro entity)
|
||||||
|
- `appointments.representation_id` → `representations.id` (clinic_pro entity)
|
||||||
|
- `appointments` ← `payments.field_reference_id` (OneToOne)
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- تایمزون: `Asia/Tehran`
|
||||||
|
- `start_time` و `end_time` هر دو Unix timestamp هستند (INT)
|
||||||
|
- `slot.time` ساعت شروع برای نمایش است (HH:MM)
|
||||||
|
- نوبت ابتدا `waiting_for_payment` → بعد پرداخت → `reserved`
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۱۰: ماژول نوبتدهی
|
||||||
|
|
||||||
|
## وضعیتهای واقعی نوبت (از Drupal)
|
||||||
|
```
|
||||||
|
waiting_for_payment → وضعیت پیشفرض هنگام ثبت نوبت
|
||||||
|
confirmed → بعد از پرداخت موفق
|
||||||
|
auto_cancel_unpaid → لغو خودکار به دلیل عدم پرداخت
|
||||||
|
cancelled_by_patient → لغو توسط بیمار
|
||||||
|
cancelled_by_doctor → لغو توسط دکتر
|
||||||
|
```
|
||||||
|
⚠️ در طراحی اولیه `pending/cancelled/completed` بود — اینها **اشتباه** بودند.
|
||||||
|
|
||||||
|
## تشخیص نماینده از HTTP Host (Multi-tenant)
|
||||||
|
```php
|
||||||
|
// در AppointmentService.php Drupal:
|
||||||
|
// نماینده از domain_name=host پیدا میشود
|
||||||
|
private function getRepresentation(string $host): ?int {
|
||||||
|
return $this->representationRepo->findByDomainName($host)?->getId();
|
||||||
|
}
|
||||||
|
// در Symfony: از $request->getHost() استفاده کن
|
||||||
|
$host = $request->getSchemeAndHttpHost() . '/'; // e.g. http://yasuj-nobat.localhost:3000/
|
||||||
|
$representation = $this->representationRepo->findByDomainName($host);
|
||||||
|
```
|
||||||
|
|
||||||
|
## فیلدهای واقعی نوبت (از کد Drupal)
|
||||||
|
```
|
||||||
|
field_doctor_id → entity reference به doctor
|
||||||
|
field_start_time → Unix timestamp (Asia/Tehran)
|
||||||
|
field_end_time → Unix timestamp (Asia/Tehran)
|
||||||
|
field_address → entity reference به doctor_address
|
||||||
|
field_slot → JSON: {start_time_timestamp, end_time_timestamp, location_id, start, end, duration}
|
||||||
|
field_representation → entity reference به representation
|
||||||
|
field_status → string (waiting_for_payment, confirmed, ...)
|
||||||
|
field_visited_at → Unix timestamp (بعد از ویزیت)
|
||||||
|
field_info → یادداشت
|
||||||
|
```
|
||||||
|
|
||||||
|
## اعتبارسنجی slot (از کد Drupal)
|
||||||
|
```php
|
||||||
|
// بررسی start_time معتبر بودن (در آینده، نه گذشته)
|
||||||
|
$checkStartTime = $this->isTimestampValid($startTime, 10); // 10 دقیقه حداقل
|
||||||
|
$checkEndTime = $this->isTimestampValid($endTime, 10);
|
||||||
|
|
||||||
|
// بررسی تداخل (conflict check)
|
||||||
|
$unacceptableStatus = ['auto_cancel_unpaid', 'cancelled_by_patient', 'cancelled_by_doctor'];
|
||||||
|
// اگر نوبتی برای همین doctor + slot وجود داشت که status آن در لیست بالا نبود → خطا
|
||||||
|
```
|
||||||
|
|
||||||
|
## جلوگیری از Race Condition
|
||||||
|
از database transaction + pessimistic write lock استفاده کن:
|
||||||
|
```php
|
||||||
|
$this->entityManager->beginTransaction();
|
||||||
|
try {
|
||||||
|
$existing = $this->repo->findConflictingAppointment(
|
||||||
|
$doctorId, $startTime, $endTime,
|
||||||
|
lockMode: LockMode::PESSIMISTIC_WRITE
|
||||||
|
);
|
||||||
|
if ($existing) throw new SlotAlreadyTakenException();
|
||||||
|
|
||||||
|
$appointment = new Appointment(...);
|
||||||
|
$this->entityManager->persist($appointment);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$this->entityManager->commit();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->entityManager->rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response کامل نوبت (از finalizedData Drupal)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"uuid": "...",
|
||||||
|
"status": "waiting_for_payment",
|
||||||
|
"start_time": 1716000000,
|
||||||
|
"end_time": 1716001800,
|
||||||
|
"visited_at": null,
|
||||||
|
"info": null,
|
||||||
|
"slot": {
|
||||||
|
"start_time_timestamp": 1716000000,
|
||||||
|
"end_time_timestamp": 1716001800,
|
||||||
|
"location_id": 42,
|
||||||
|
"start": "09:00",
|
||||||
|
"end": "09:30",
|
||||||
|
"duration": 30
|
||||||
|
},
|
||||||
|
"doctor": {
|
||||||
|
"id": 5,
|
||||||
|
"uuid": "...",
|
||||||
|
"name": "دکتر محمدی",
|
||||||
|
"specialty": {"id": 3, "uuid": "...", "name": "متخصص قلب"}
|
||||||
|
},
|
||||||
|
"address": {
|
||||||
|
"id": 42, "uuid": "...", "name": "مطب شیراز",
|
||||||
|
"address": "...", "phone": "071...",
|
||||||
|
"map": {"latitude": 29.6, "longitude": 52.5}
|
||||||
|
},
|
||||||
|
"patient": {
|
||||||
|
"id": 10, "uuid": "...",
|
||||||
|
"name": "علی رضایی",
|
||||||
|
"mobile": "09120000000",
|
||||||
|
"profile": {"id": 8, "uuid": "..."}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## روزهای غیر قابل رزرو
|
||||||
|
endpoint `GET /appointment/not-available/{doctorId}` تاریخهایی را برمیگرداند که در آنها نوبت خالی نیست:
|
||||||
|
- روزهایی در Holiday جای گرفتهاند
|
||||||
|
- روزهایی که DateOverride با active=false دارند
|
||||||
|
- روزهایی که WeeklySchedule آنها active=false است
|
||||||
|
- روزهایی که همه slotهایشان رزرو فعال دارند
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
POST /appointment → احراز هویتشده
|
||||||
|
GET /appointment-slots/{doctorId} → عمومی
|
||||||
|
GET /appointment/not-available/{id} → عمومی
|
||||||
|
GET /appointment/my-appointments/{id} → owner یا ROLE_ADMIN
|
||||||
|
PATCH /appointment/{uuid}/status → ROLE_ADMIN یا دکتر مرتبط
|
||||||
|
```
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# تسک ۱۰: ماژول نوبتدهی
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
سیستم رزرو نوبت شامل نمایش اسلاتهای خالی، رزرو نوبت، لغو نوبت،
|
||||||
|
روزهای غیرقابل رزرو و لیست نوبتهای کاربر.
|
||||||
|
|
||||||
|
## Endpoint ها
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| GET | `/api/v1/appointment-slots` | اسلاتهای خالی دکتر در تاریخ | خیر |
|
||||||
|
| POST | `/api/v1/appointment` | رزرو نوبت | بله |
|
||||||
|
| GET | `/api/v1/appointment/not-available/{doctorId}` | روزهای غیرقابل رزرو | خیر |
|
||||||
|
| GET | `/api/v1/appointment/my-appointments/{userId}` | نوبتهای من | بله |
|
||||||
|
| PATCH | `/api/v1/appointment/{uuid}/cancel` | لغو نوبت توسط کاربر | بله (Owner) |
|
||||||
|
| PATCH | `/api/v1/appointment/{uuid}/status` | تغییر وضعیت نوبت | بله (Doctor/Secretary/Admin) |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۰۹ (تنظیمات)، ۱۵ (Payment)
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۱۲ تا ۱۵ ساعت
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status Machine نوبت
|
||||||
|
|
||||||
|
```
|
||||||
|
[ایجاد نوبت]
|
||||||
|
↓
|
||||||
|
waiting_for_payment ──→ (پرداخت موفق) ──→ reserved
|
||||||
|
↓ ↓
|
||||||
|
(لغو) ┌────────────┤
|
||||||
|
↓ │ │
|
||||||
|
cancelled_by_patient checked_in (لغو دکتر)
|
||||||
|
↓ ↓
|
||||||
|
waiting cancelled_by_doctor
|
||||||
|
↓
|
||||||
|
in_progress
|
||||||
|
↓
|
||||||
|
┌─────────────┴─────────────┐
|
||||||
|
↓ ↓
|
||||||
|
visited no_show
|
||||||
|
↓
|
||||||
|
completed
|
||||||
|
```
|
||||||
|
|
||||||
|
**وضعیتها:**
|
||||||
|
| وضعیت | توضیح | چه کسی تغییر میدهد |
|
||||||
|
|--------|-------|---------------------|
|
||||||
|
| `waiting_for_payment` | منتظر پرداخت | سیستم — بعد از رزرو |
|
||||||
|
| `reserved` | رزرو شده — پرداخت موفق | سیستم — بعد از تأیید پرداخت |
|
||||||
|
| `checked_in` | بیمار به مطب رسیده | منشی/دکتر |
|
||||||
|
| `waiting` | در صف انتظار مطب | منشی/دکتر |
|
||||||
|
| `in_progress` | ویزیت در حال انجام | منشی/دکتر |
|
||||||
|
| `visited` | ویزیت انجام شد | منشی/دکتر |
|
||||||
|
| `no_show` | بیمار نیامد | منشی/دکتر |
|
||||||
|
| `completed` | کامل شد | سیستم |
|
||||||
|
| `cancelled_by_patient` | لغو توسط بیمار | بیمار (Owner) |
|
||||||
|
| `cancelled_by_doctor` | لغو توسط دکتر | دکتر/Admin |
|
||||||
|
| `postponed` | به تعویق افتاده | دکتر/Admin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فلوی کامل رزرو + پرداخت
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/appointment
|
||||||
|
↓
|
||||||
|
1. بررسی اسلات: آیا time در آن date خالی است؟
|
||||||
|
2. بررسی holiday/date_override
|
||||||
|
3. ایجاد appointment با status=waiting_for_payment
|
||||||
|
4. بازگشت uuid نوبت به کلاینت
|
||||||
|
↓
|
||||||
|
POST /api/v1/payment (در task-15)
|
||||||
|
{ appointment_uuid: "...", payment_method: "mellat" }
|
||||||
|
↓
|
||||||
|
5. ایجاد payment با status=pending
|
||||||
|
6. دریافت payment_url از درگاه
|
||||||
|
7. redirect کاربر به درگاه
|
||||||
|
↓
|
||||||
|
[Callback از درگاه بانک]
|
||||||
|
↓
|
||||||
|
8. تأیید پرداخت → payments.status = 'received'
|
||||||
|
9. appointments.status = 'reserved'
|
||||||
|
10. واریز کمیسیون به کیف پول نماینده (اگر از دامنه نماینده)
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠ نکته:** اگر در ۳۰ دقیقه پرداخت نشود → `waiting_for_payment` به `cancelled_by_system` تغییر کند (job)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/v1/appointment-slots
|
||||||
|
|
||||||
|
```
|
||||||
|
Query params:
|
||||||
|
doctor_uuid (الزامی)
|
||||||
|
date (الزامی) — فرمت: YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"date": "2024-03-20",
|
||||||
|
"doctor": { "uuid": "...", "name": "دکتر احمدی" },
|
||||||
|
"slots": [
|
||||||
|
{ "time": "09:00", "available": true, "duration": 30 },
|
||||||
|
{ "time": "09:30", "available": false, "duration": 30 },
|
||||||
|
{ "time": "10:00", "available": true, "duration": 30 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**منطق محاسبه اسلاتهای خالی:**
|
||||||
|
```
|
||||||
|
1. بارگذاری weekly_schedule دکتر برای روز هفته مربوطه
|
||||||
|
2. بررسی date_override برای تاریخ مشخص
|
||||||
|
3. بررسی holiday (اگر تاریخ در بازه تعطیلی است → همه اسلاتها unavailable)
|
||||||
|
4. خواندن نوبتهای موجود با status ≠ cancelled → آن اسلاتها unavailable
|
||||||
|
5. بازگشت لیست اسلاتها با وضعیت available/unavailable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/v1/appointment
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"doctor_uuid": "61be915b-...",
|
||||||
|
"date": "2024-03-20",
|
||||||
|
"time": "09:00",
|
||||||
|
"address_id": 39,
|
||||||
|
"insurance_type_id": null,
|
||||||
|
"notes": "درد معده دارم"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 201
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "...",
|
||||||
|
"doctor": { "uuid": "...", "name": "دکتر احمدی" },
|
||||||
|
"date": "2024-03-20",
|
||||||
|
"time": "09:00",
|
||||||
|
"status": "waiting_for_payment",
|
||||||
|
"created_at": 1748000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 409 — اسلات گرفته شده
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"errors": [{ "code": "ERR_APPOINTMENT_001", "message": "اسلات انتخابشده در دسترس نیست" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PATCH /api/v1/appointment/{uuid}/cancel — لغو نوبت
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "reason": "به دلیل بیماری نمیتوانم بیایم" }
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "...",
|
||||||
|
"status": "cancelled_by_patient",
|
||||||
|
"refund_status": "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 400 — نوبت قابل لغو نیست
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"errors": [{ "code": "ERR_APPOINTMENT_002", "message": "نوبت در وضعیت فعلی قابل لغو نیست" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**قوانین لغو:**
|
||||||
|
- فقط نوبتهای با status `waiting_for_payment` یا `reserved` قابل لغو هستند
|
||||||
|
- اگر پرداخت شده (`reserved`) → `payments.status = 'refund'` و refund شروع میشود
|
||||||
|
- لغو بعد از `checked_in` فقط توسط Admin/Doctor مجاز است
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PATCH /api/v1/appointment/{uuid}/status
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request (Doctor/Secretary/Admin)
|
||||||
|
{ "status": "checked_in" }
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "...",
|
||||||
|
"status": "checked_in",
|
||||||
|
"updated_at": 1748000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Transition های مجاز:**
|
||||||
|
```
|
||||||
|
reserved → checked_in (Doctor/Secretary)
|
||||||
|
checked_in → waiting (Doctor/Secretary)
|
||||||
|
waiting → in_progress (Doctor/Secretary)
|
||||||
|
in_progress → visited (Doctor/Secretary)
|
||||||
|
in_progress → no_show (Doctor/Secretary)
|
||||||
|
visited → completed (System/Doctor)
|
||||||
|
reserved → cancelled_by_doctor (Doctor/Admin)
|
||||||
|
reserved → postponed (Doctor/Admin)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/v1/appointment/not-available/{doctorId}
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"not_available_dates": [
|
||||||
|
"2024-03-20",
|
||||||
|
"2024-03-21",
|
||||||
|
"2024-04-01"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**منطق:**
|
||||||
|
- روزهایی که holiday هستند
|
||||||
|
- روزهایی که date_override با `active=false` تعریف شده
|
||||||
|
- روزهایی که همه اسلاتها پر هستند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/v1/appointment/my-appointments/{userId}
|
||||||
|
|
||||||
|
```
|
||||||
|
Query params:
|
||||||
|
status (اختیاری) — فیلتر بر اساس وضعیت
|
||||||
|
page (اختیاری، پیشفرض 1)
|
||||||
|
limit (اختیاری، پیشفرض 10)
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"uuid": "...",
|
||||||
|
"doctor": {
|
||||||
|
"uuid": "...",
|
||||||
|
"name": "دکتر احمدی",
|
||||||
|
"specialty": "قلب و عروق",
|
||||||
|
"img": [{ "url": "..." }]
|
||||||
|
},
|
||||||
|
"date": "2024-03-20",
|
||||||
|
"time": "09:00",
|
||||||
|
"status": "reserved",
|
||||||
|
"payment_status": "received",
|
||||||
|
"created_at": 1748000000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": { "totalRecords": 12, "totalPages": 2, "currentPage": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **Optimistic Locking:** هنگام رزرو اسلات، از Transaction + Lock استفاده شود تا race condition نباشد
|
||||||
|
- **Expiry Job:** نوبتهای `waiting_for_payment` بعد از ۳۰ دقیقه باید auto-cancel شوند (Symfony Scheduler)
|
||||||
|
- **N+1 Prevention:** در لیست نوبتها، دکتر و وضعیت پرداخت با eager loading بارگذاری شوند
|
||||||
|
- **Timestamps:** همه تاریخ/زمانها Unix timestamp (INT) ذخیره میشوند
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# جریان کاربری — تسک ۱۰: نوبتدهی
|
||||||
|
|
||||||
|
## جریان کامل رزرو نوبت
|
||||||
|
|
||||||
|
```
|
||||||
|
کاربر دکتر را انتخاب میکند
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GET /api/v1/appointment/not-available/{doctorId}
|
||||||
|
→ دریافت تاریخهای غیر قابل رزرو (برای کالندار)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
کاربر تاریخ مورد نظر را انتخاب میکند
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
GET /api/v1/appointment-slots?doctor_uuid=...&date=...
|
||||||
|
→ دریافت اسلاتهای خالی آن روز
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
کاربر ساعت مورد نظر را انتخاب میکند
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
POST /api/v1/appointment
|
||||||
|
{ doctor_uuid, date, time, insurance_type, notes }
|
||||||
|
│
|
||||||
|
├─► بررسی موجود بودن اسلات
|
||||||
|
├─► ایجاد appointment با status=pending
|
||||||
|
└─► ایجاد payment با status=pending (تسک ۱۵)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
کاربر به درگاه پرداخت هدایت میشود (تسک ۱۵)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
بعد از پرداخت موفق:
|
||||||
|
appointment.status = confirmed
|
||||||
|
payment.status = paid
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
ارسال پیامک تأیید به کاربر و دکتر
|
||||||
|
```
|
||||||
|
|
||||||
|
## جریان مشاهده نوبتهای من
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/appointment/my-appointments/{userId}
|
||||||
|
→ لیست همه نوبتها (گذشته و آینده)
|
||||||
|
→ به صورت صعودی بر اساس تاریخ مرتبشده
|
||||||
|
```
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# معماری — تسک ۱۱: ماژول بیمه
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Insurance/
|
||||||
|
├── Controller/
|
||||||
|
│ └── InsuranceController.php
|
||||||
|
├── Service/
|
||||||
|
│ └── InsuranceService.php
|
||||||
|
├── Repository/
|
||||||
|
│ └── InsuranceRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ └── Insurance.php
|
||||||
|
└── DTO/
|
||||||
|
├── Request/
|
||||||
|
│ ├── CreateInsuranceRequest.php
|
||||||
|
│ └── UpdateInsuranceRequest.php
|
||||||
|
└── Response/
|
||||||
|
└── InsuranceResponse.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Insurance
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'doctor_insurances')]
|
||||||
|
class Insurance
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private Doctor $doctor;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Category::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
private Category $insuranceCategory; // از جدول categories
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', default: true)]
|
||||||
|
private bool $isActive = true;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# پایگاه داده — تسک ۱۱: ماژول بیمه
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
در Drupal، بیمهها به عنوان دستهبندی (`category` entity) ذخیره میشوند:
|
||||||
|
- bundle=`insurance_type` → بیمههای پایه
|
||||||
|
- bundle=`supplementary_insurance` → بیمههای تکمیلی
|
||||||
|
|
||||||
|
رابطه doctor-insurance از طریق `field_insurance` روی bundle=clinic در Drupal موجود است (نه مستقیم روی doctor).
|
||||||
|
در profile کاربر: `field_basic_insurance` و `field_supplementary_insurance` (entity ref → category).
|
||||||
|
|
||||||
|
## جدول: doctor_insurances (رابطه doctor ↔ insurance)
|
||||||
|
_(از endpoint واقعی: PATCH /api/v1/insurance/1 دارای field_price است)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT AUTO_INCREMENT PK | id | |
|
||||||
|
| doctor_id | INT FK → doctors.id CASCADE | field_doctor | دکتر |
|
||||||
|
| category_id | INT FK → categories.id | field_insurance_type | نوع بیمه (bundle=insurance_type یا supplementary_insurance) |
|
||||||
|
| price | INT NULL | field_price | مبلغ ویزیت با این بیمه (تومان — اختیاری) |
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_doctor_insurance ON doctor_insurances(doctor_id, category_id);
|
||||||
|
CREATE INDEX idx_doctor_insurance_cat ON doctor_insurances(category_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- دادههای بیمه در جدول `categories` ذخیره میشوند — نه جدول جداگانه
|
||||||
|
- `clinic_insurances` هم وجود دارد: رابطه clinic ↔ insurance (تسک ۰۶)
|
||||||
|
- بیمه کاربر در `profiles` ذخیره میشود: field_basic_insurance, field_supplementary_insurance (تسک ۰۳)
|
||||||
|
- هیچ timestamp در این pivot table لازم نیست
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۱۱: ماژول بیمه
|
||||||
|
|
||||||
|
## رابطه با Categories
|
||||||
|
این ماژول از جدول `categories` برای نام و نوع بیمه استفاده میکند.
|
||||||
|
هنگام create، فقط `insurance_category_id` کافی است.
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
POST → دکتر (برای خودش) یا ROLE_ADMIN
|
||||||
|
GET → دکتر مرتبط یا ROLE_ADMIN
|
||||||
|
PATCH → دکتر مرتبط یا ROLE_ADMIN
|
||||||
|
DELETE → دکتر مرتبط یا ROLE_ADMIN
|
||||||
|
```
|
||||||
|
|
||||||
|
## یکپارچگی با لیست دکتر
|
||||||
|
در endpoint `GET /api/v1/doctors`، بیمههای هر دکتر باید
|
||||||
|
به عنوان فیلد در response ظاهر شوند.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# تسک ۱۱: ماژول بیمه
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
مدیریت بیمههای مرتبط با دکتر یا کلینیک (بیمههایی که دکتر میپذیرد).
|
||||||
|
|
||||||
|
## Endpoint ها
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/insurance/` | ایجاد رابطه بیمه | بله (Doctor/Admin) |
|
||||||
|
| GET | `/api/v1/insurance/{id}` | دریافت اطلاعات بیمه | بله |
|
||||||
|
| PATCH | `/api/v1/insurance/{id}` | ویرایش بیمه | بله |
|
||||||
|
| DELETE | `/api/v1/insurance/{id}` | حذف بیمه | بله |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۰۸ (Categories)
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۳ تا ۴ ساعت
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
این ماژول مشخص میکند که یک دکتر کدام بیمهها را میپذیرد.
|
||||||
|
دادههای اصلی بیمه در ماژول Categories هستند (تسک ۰۸).
|
||||||
|
این جدول رابطه دکتر ↔ بیمه را ذخیره میکند.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# معماری — تسک ۱۲: ماژول امتیاز و نظرات
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Rating/
|
||||||
|
├── Controller/
|
||||||
|
│ ├── RatingController.php
|
||||||
|
│ └── CommentController.php
|
||||||
|
├── Service/
|
||||||
|
│ ├── RatingService.php ← آپدیت average_rating دکتر
|
||||||
|
│ └── CommentService.php
|
||||||
|
├── Repository/
|
||||||
|
│ ├── RatingRepository.php
|
||||||
|
│ └── CommentRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ ├── Rating.php
|
||||||
|
│ └── Comment.php
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ ├── CreateRatingRequest.php
|
||||||
|
│ │ ├── CreateCommentRequest.php
|
||||||
|
│ │ └── ConfirmCommentRequest.php
|
||||||
|
│ └── Response/
|
||||||
|
│ ├── RatingResponse.php
|
||||||
|
│ └── CommentResponse.php
|
||||||
|
└── Voter/
|
||||||
|
├── RatingVoter.php
|
||||||
|
└── CommentVoter.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Rating
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'ratings')]
|
||||||
|
class Rating
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
private User $patient;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||||
|
private Doctor $doctor;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $score; // 1 تا 5
|
||||||
|
|
||||||
|
#[ORM\OneToOne(targetEntity: Appointment::class, nullable: true)]
|
||||||
|
private ?Appointment $appointment;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Comment
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'comments')]
|
||||||
|
class Comment
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
private User $author;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||||
|
private Doctor $doctor;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'text')]
|
||||||
|
private string $text;
|
||||||
|
|
||||||
|
// pending, approved, rejected
|
||||||
|
#[ORM\Column(length: 20, default: 'pending')]
|
||||||
|
private string $status;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Rating::class, nullable: true)]
|
||||||
|
private ?Rating $rating;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# پایگاه داده — تسک ۱۲: ماژول امتیاز و نظرات
|
||||||
|
|
||||||
|
## مهم: نام فیلد از DB
|
||||||
|
فیلد ستاره در Drupal **`field_starts`** است (نه `field_stars`!) — از config تأیید شد.
|
||||||
|
|
||||||
|
## جدول: ratings
|
||||||
|
_(entity_type=clinic_pro_comment, bundle=rate)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | uid | امتیازدهنده |
|
||||||
|
| doctor_id | INT FK → doctors.id NOT NULL | field_doctor_id | entity ref → clinic_pro |
|
||||||
|
| doctor_behavior | INT NOT NULL | field_doctor_behavior | برخورد مناسب (0-100) |
|
||||||
|
| accuracy_of_diagnosis | INT NOT NULL | field_accuracy_of_diagnosis | تشخیص درست (0-100) |
|
||||||
|
| waiting_time_at_clinic | INT NOT NULL | field_waiting_time_at_clinic | زمان انتظار (0-100) |
|
||||||
|
| doctor_expertise | INT NOT NULL | field_doctor_expertise | مهارت (0-100) |
|
||||||
|
| clinic_cleanliness | INT NOT NULL | field_clinic_cleanliness | نظافت (0-100) |
|
||||||
|
| starts | DECIMAL(10,2) NOT NULL | field_starts | ستاره محاسبهشده (0-5) — ⚠️ `starts` نه `stars`! |
|
||||||
|
| percent | FLOAT NOT NULL | field_percent | درصد محاسبهشده (0-100) |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## جدول: comments
|
||||||
|
_(entity_type=clinic_pro_comment, bundle=comments)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | uid | نویسنده |
|
||||||
|
| doctor_id | INT FK → doctors.id NOT NULL | field_doctor_id | دکتر |
|
||||||
|
| comment | LONGTEXT NOT NULL | field_comment | متن نظر |
|
||||||
|
| approved | TINYINT(1) DEFAULT 0 | field_approved | تأیید شده (نه ENUM بلکه boolean) |
|
||||||
|
| parent_id | INT FK → comments.id NULL | field_parent | نظر پدر (پاسخ به نظر) |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## جدول: likes
|
||||||
|
_(entity_type=clinic_pro_comment, bundle=like)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | uid | کاربر |
|
||||||
|
| comment_id | INT FK → comments.id NOT NULL | field_comment_id | نظر مورد لایک |
|
||||||
|
| is_like | TINYINT(1) NOT NULL | field_like | لایک (1) یا دیسلایک (0) |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
-- هر کاربر فقط یک امتیاز برای هر دکتر
|
||||||
|
CREATE UNIQUE INDEX idx_ratings_user_doctor ON ratings(user_id, doctor_id);
|
||||||
|
|
||||||
|
-- هر کاربر فقط یک لایک/دیسلایک برای هر نظر
|
||||||
|
CREATE UNIQUE INDEX idx_likes_user_comment ON likes(user_id, comment_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_ratings_doctor ON ratings(doctor_id);
|
||||||
|
CREATE INDEX idx_comments_doctor_approved ON comments(doctor_id, approved);
|
||||||
|
CREATE INDEX idx_comments_parent ON comments(parent_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکتههای مهم
|
||||||
|
- فیلد ستاره `starts` است (نه `stars`) — همانطور که در config تأیید شد
|
||||||
|
- `approved` boolean است (0/1)، نه ENUM
|
||||||
|
- `percent` نوع FLOAT است (نه DECIMAL) — از config تأیید شد
|
||||||
|
- `starts` نوع DECIMAL(10,2) است — از config تأیید شد
|
||||||
|
- `comment.status` در جدول پایه Drupal وجود دارد (tinyint) اما از `field_approved` استفاده میشود
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۱۲: ماژول امتیاز و نظرات
|
||||||
|
|
||||||
|
## ⚠ تناقض نام فیلدها: API vs DB (بسیار مهم!)
|
||||||
|
|
||||||
|
فیلدهایی که **کلاینت ارسال میکند** با نام فیلدهای **پایگاه داده** متفاوت هستند:
|
||||||
|
|
||||||
|
| نام در Request (API) | نام در DB (Drupal field) | توضیح |
|
||||||
|
|---------------------|------------------------|-------|
|
||||||
|
| `correct_diagnosis` | `accuracy_of_diagnosis` | دقت تشخیص |
|
||||||
|
| `doctor_skill` | `doctor_expertise` | مهارت پزشک |
|
||||||
|
| `behavior_doctor` | `doctor_behavior` | برخورد پزشک |
|
||||||
|
| `office_cleaning` | `clinic_cleanliness` | نظافت مطب |
|
||||||
|
| `time_in_office` | `waiting_time_at_clinic` | زمان انتظار |
|
||||||
|
| `doctor` | `doctor_id` | شناسه دکتر (integer) |
|
||||||
|
| `rate` | `starts` | امتیاز ستارهای (DECIMAL 10,2) |
|
||||||
|
|
||||||
|
**در Symfony باید:**
|
||||||
|
- ورودی را با نامهای API دریافت کن (`correct_diagnosis`, ...)
|
||||||
|
- در Entity و DB با نامهای Drupal ذخیره کن (`accuracy_of_diagnosis`, ...)
|
||||||
|
|
||||||
|
## نمونه واقعی Request — POST /api/v1/clinicpro/rate
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"correct_diagnosis": 100,
|
||||||
|
"doctor_skill": 100,
|
||||||
|
"behavior_doctor": 100,
|
||||||
|
"office_cleaning": 100,
|
||||||
|
"time_in_office": 100,
|
||||||
|
"doctor": 1,
|
||||||
|
"rate": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## نمونه واقعی Request — PATCH /api/v1/clinicpro/rate/{uuid}
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"correct_diagnosis": 50,
|
||||||
|
"doctor_skill": 60,
|
||||||
|
"behavior_doctor": 70,
|
||||||
|
"office_cleaning": 80,
|
||||||
|
"time_in_office": 90,
|
||||||
|
"doctor": 1,
|
||||||
|
"rate": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## سیستم امتیازدهی وزنی
|
||||||
|
|
||||||
|
Rating در Drupal **5 معیار جداگانه** دارد که هر کدام مقدار 0-100 میگیرند
|
||||||
|
و با وزنهای متفاوت محاسبه میشوند:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$weights = [
|
||||||
|
"doctor_behavior" => 1.5, // behavior_doctor در API
|
||||||
|
"accuracy_of_diagnosis" => 3.0, // correct_diagnosis در API
|
||||||
|
"waiting_time_at_clinic" => 1.0, // time_in_office در API
|
||||||
|
"doctor_expertise" => 2.0, // doctor_skill در API
|
||||||
|
"clinic_cleanliness" => 1.0, // office_cleaning در API
|
||||||
|
];
|
||||||
|
|
||||||
|
// فرمول محاسبه:
|
||||||
|
$weightedAverage = SUM(value * weight) / SUM(weights); // از 100
|
||||||
|
$stars = ($weightedAverage / 100) * 5; // از 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### پیادهسازی calculateDoctorRating در Symfony
|
||||||
|
```php
|
||||||
|
public function calculateRating(array $apiScores): array
|
||||||
|
{
|
||||||
|
// نگاشت نامهای API به نامهای DB
|
||||||
|
$mapped = [
|
||||||
|
'accuracy_of_diagnosis' => $apiScores['correct_diagnosis'] ?? 0,
|
||||||
|
'doctor_expertise' => $apiScores['doctor_skill'] ?? 0,
|
||||||
|
'doctor_behavior' => $apiScores['behavior_doctor'] ?? 0,
|
||||||
|
'clinic_cleanliness' => $apiScores['office_cleaning'] ?? 0,
|
||||||
|
'waiting_time_at_clinic' => $apiScores['time_in_office'] ?? 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$weights = [
|
||||||
|
'doctor_behavior' => 1.5,
|
||||||
|
'accuracy_of_diagnosis' => 3.0,
|
||||||
|
'waiting_time_at_clinic' => 1.0,
|
||||||
|
'doctor_expertise' => 2.0,
|
||||||
|
'clinic_cleanliness' => 1.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$totalScore = 0.0;
|
||||||
|
$totalWeight = 0.0;
|
||||||
|
|
||||||
|
foreach ($weights as $key => $weight) {
|
||||||
|
$totalScore += $mapped[$key] * $weight;
|
||||||
|
$totalWeight += $weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weightedAverage = $totalWeight > 0 ? $totalScore / $totalWeight : 0;
|
||||||
|
$stars = ($weightedAverage / 100) * 5;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'percent' => round($weightedAverage, 1),
|
||||||
|
'starts' => round(min(5.0, max(0.0, $stars)), 2),
|
||||||
|
// ⚠️ نام فیلد DB: "starts" است نه "stars"!
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## آمار دکتر — GET /api/v1/clinicpro-comment/doctor-rate/{doctorUuid}
|
||||||
|
|
||||||
|
```
|
||||||
|
URL: /api/v1/clinicpro-comment/doctor-rate/{uuid_دکتر}
|
||||||
|
Auth: عمومی (بدون احراز هویت)
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"average_stars": 4.3,
|
||||||
|
"total_rates": 87,
|
||||||
|
"averages": {
|
||||||
|
"average_doctor_behavior": 82.1,
|
||||||
|
"average_accuracy_of_diagnosis": 88.5,
|
||||||
|
"average_waiting_time_at_clinic": 65.3,
|
||||||
|
"average_doctor_expertise": 90.2,
|
||||||
|
"average_clinic_cleanliness": 78.4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تأیید نظرات
|
||||||
|
نظرات با `approved=0` ذخیره میشوند.
|
||||||
|
ادمین آنها را از `GET /api/v1/clinicpro/unverified-comments/{doctorId}` میبیند.
|
||||||
|
سپس با `PATCH /api/v1/clinicpro/unverified-comments/{commentId}` تأیید میکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## اعتبارسنجی مقادیر Rating
|
||||||
|
هر معیار باید بین 0 تا 100 باشد:
|
||||||
|
```php
|
||||||
|
#[Assert\Range(min: 0, max: 100)]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
POST /api/v1/clinicpro/rate → احراز هویتشده
|
||||||
|
PATCH /api/v1/clinicpro/rate/{uuid} → owner (هر کاربر فقط یک امتیاز برای هر دکتر)
|
||||||
|
DELETE /api/v1/rate/doctor/{uuid} → ROLE_ADMIN
|
||||||
|
GET /api/v1/clinicpro/rate/{uuid} → owner (امتیاز کاربر برای دکتر مشخص)
|
||||||
|
GET /api/v1/clinicpro-comment/doctor-rate/{uuid} → عمومی (آمار کلی دکتر)
|
||||||
|
POST /api/v1/clinicpro/comment → احراز هویتشده
|
||||||
|
PATCH /api/v1/clinicpro/comment/{uuid} → owner یا ROLE_ADMIN
|
||||||
|
DELETE /api/v1/clinicpro/comment/{uuid} → owner یا ROLE_ADMIN
|
||||||
|
GET /api/v1/clinicpro/comment/{uuid} → احراز هویتشده
|
||||||
|
GET /api/v1/clinicpro/comments/{doctorId} → احراز هویتشده (فقط approved)
|
||||||
|
GET /api/v1/clinicpro/unverified-comments/{doctorId} → ROLE_ADMIN
|
||||||
|
PATCH /api/v1/clinicpro/unverified-comments/{id} → ROLE_ADMIN (تأیید/رد نظر)
|
||||||
|
POST /api/v1/clinicpro/like → احراز هویتشده
|
||||||
|
PATCH /api/v1/clinicpro/like/{uuid} → owner
|
||||||
|
```
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# تسک ۱۲: ماژول امتیاز و نظرات
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
سیستم امتیازدهی (rate) و نظرات (comment) و لایک کاربران برای دکترها،
|
||||||
|
شامل تأیید نظرات توسط ادمین.
|
||||||
|
|
||||||
|
## Endpoint ها (واقعی از Drupal)
|
||||||
|
|
||||||
|
### امتیازدهی (Rate)
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/clinicpro/rate` | ثبت امتیاز جدید | بله |
|
||||||
|
| PATCH | `/api/v1/clinicpro/rate/{uuid}` | ویرایش امتیاز | بله (Owner) |
|
||||||
|
| DELETE | `/api/v1/rate/doctor/{uuid}` | حذف امتیاز | بله (Admin) |
|
||||||
|
| GET | `/api/v1/clinicpro/rate/{uuid}` | امتیاز من برای دکتر | بله |
|
||||||
|
| GET | `/api/v1/clinicpro-comment/doctor-rate/{doctor_uuid}` | آمار کلی امتیازهای دکتر | خیر |
|
||||||
|
|
||||||
|
### نظرات (Comment)
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/clinicpro/comment` | ثبت نظر جدید | بله |
|
||||||
|
| PATCH | `/api/v1/clinicpro/comment/{uuid}` | ویرایش نظر | بله (Owner/Admin) |
|
||||||
|
| DELETE | `/api/v1/clinicpro/comment/{uuid}` | حذف نظر | بله (Owner/Admin) |
|
||||||
|
| GET | `/api/v1/clinicpro/comment/{uuid}` | دریافت یک نظر | بله |
|
||||||
|
| GET | `/api/v1/clinicpro/comments/{doctorId}` | لیست نظرات دکتر (با page/limit) | بله |
|
||||||
|
| GET | `/api/v1/clinicpro/unverified-comments/{doctorId}` | نظرات تأییدنشده (با page/limit) | بله (Admin) |
|
||||||
|
| PATCH | `/api/v1/clinicpro/unverified-comments/{commentId}` | تأیید/رد نظر | بله (Admin) |
|
||||||
|
|
||||||
|
### لایک (Like)
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/clinicpro/like` | ثبت لایک/دیسلایک | بله |
|
||||||
|
| PATCH | `/api/v1/clinicpro/like/{uuid}` | ویرایش لایک | بله (Owner) |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱، ۰۲، ۰۵ (Doctor)
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۸ تا ۱۰ ساعت
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠ نام فیلدهای Request (متفاوت از DB!)
|
||||||
|
|
||||||
|
| فیلد در Request | معادل در DB | توضیح |
|
||||||
|
|----------------|------------|-------|
|
||||||
|
| `correct_diagnosis` | `accuracy_of_diagnosis` | دقت تشخیص (0-100) |
|
||||||
|
| `doctor_skill` | `doctor_expertise` | مهارت پزشک (0-100) |
|
||||||
|
| `behavior_doctor` | `doctor_behavior` | برخورد پزشک (0-100) |
|
||||||
|
| `office_cleaning` | `clinic_cleanliness` | نظافت مطب (0-100) |
|
||||||
|
| `time_in_office` | `waiting_time_at_clinic` | زمان انتظار (0-100) |
|
||||||
|
| `doctor` | `doctor_id` | شناسه دکتر |
|
||||||
|
| `rate` | `starts` | امتیاز ستاره (ذخیره محاسبهشده) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نمونه واقعی Request — POST /api/v1/clinicpro/rate
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"correct_diagnosis": 100,
|
||||||
|
"doctor_skill": 100,
|
||||||
|
"behavior_doctor": 100,
|
||||||
|
"office_cleaning": 100,
|
||||||
|
"time_in_office": 100,
|
||||||
|
"doctor": 1,
|
||||||
|
"rate": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## نمونه واقعی Request — PATCH /api/v1/clinicpro/rate/{uuid}
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"correct_diagnosis": 50,
|
||||||
|
"doctor_skill": 60,
|
||||||
|
"behavior_doctor": 70,
|
||||||
|
"office_cleaning": 80,
|
||||||
|
"time_in_office": 90,
|
||||||
|
"doctor": 1,
|
||||||
|
"rate": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## نمونه واقعی Request — PATCH /api/v1/clinicpro/unverified-comments/{uuid}
|
||||||
|
_(تأیید نظر — بدنه خالی یا فقط `approved`)_
|
||||||
|
```json
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- هر کاربر فقط **یک امتیاز** برای هر دکتر میتواند ثبت کند (UNIQUE user_id + doctor_id)
|
||||||
|
- نظرات با `approved=0` ذخیره میشوند و باید توسط ادمین تأیید شوند
|
||||||
|
- لایک فقط برای **نظرات** است (نه بلاگ یا دکتر)
|
||||||
|
- هر کاربر فقط **یک لایک** برای هر نظر میتواند ثبت کند
|
||||||
|
- URL کامنتهای تأییدنشده: `{doctorId}` در URL است اما فقط ROLE_ADMIN بررسی میشود
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# معماری — تسک ۱۳: ماژول لایک
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Like/
|
||||||
|
├── Controller/
|
||||||
|
│ └── LikeController.php
|
||||||
|
├── Service/
|
||||||
|
│ └── LikeService.php
|
||||||
|
├── Repository/
|
||||||
|
│ └── LikeRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ └── Like.php
|
||||||
|
└── DTO/
|
||||||
|
└── Request/
|
||||||
|
└── CreateLikeRequest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Like
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'likes')]
|
||||||
|
class Like
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
// نوع موجودیت: blog, doctor
|
||||||
|
#[ORM\Column(length: 30)]
|
||||||
|
private string $entityType;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $entityId;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', default: true)]
|
||||||
|
private bool $isLiked;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# پایگاه داده — تسک ۱۳: ماژول لایک
|
||||||
|
|
||||||
|
## ساختار واقعی از Drupal (از config تأیید شده)
|
||||||
|
|
||||||
|
در Drupal، لایک به عنوان bundle=`like` در entity `clinic_pro_comment` ذخیره میشود:
|
||||||
|
- `field_like` (boolean) → آیا لایک است یا آنلایک
|
||||||
|
- `field_comment_id` (entity_reference → comment) → لایک مربوط به کدام کامنت
|
||||||
|
|
||||||
|
لایکها **فقط** روی نظرات (comment) هستند، نه blog یا doctor.
|
||||||
|
|
||||||
|
## جدول: likes
|
||||||
|
_(entity_type=clinic_pro_comment, bundle=like)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | uid | کاربری که لایک زده |
|
||||||
|
| comment_id | INT FK → comments.id NOT NULL | field_comment_id | کامنت مورد نظر |
|
||||||
|
| is_liked | TINYINT(1) DEFAULT 1 | field_like | 1=لایک، 0=آنلایک |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_likes_user_comment ON likes(user_id, comment_id);
|
||||||
|
CREATE INDEX idx_likes_comment ON likes(comment_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- در Drupal، لایک فقط برای **comment** است (نه blog یا doctor)
|
||||||
|
- `field_like` boolean است — کاربر میتواند لایک (1) یا آنلایک (0) ثبت کند
|
||||||
|
- UNIQUE(user_id, comment_id) تضمین میکند هر کاربر فقط یک بار لایک/آنلایک بزند
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۱۳: ماژول لایک
|
||||||
|
|
||||||
|
## Toggle Like
|
||||||
|
PATCH endpoint باید is_liked را toggle کند:
|
||||||
|
```php
|
||||||
|
public function toggle(Like $like): void
|
||||||
|
{
|
||||||
|
$like->setIsLiked(!$like->isLiked());
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## جلوگیری از لایک دوگانه
|
||||||
|
unique index روی (user_id, entity_type, entity_id) جلوگیری میکند.
|
||||||
|
اگر قبلاً لایک وجود داشت، PATCH برای toggle استفاده میشود.
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
POST /like → احراز هویتشده
|
||||||
|
PATCH /like/{uuid} → owner
|
||||||
|
```
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# تسک ۱۳: ماژول لایک
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
سیستم لایک برای بلاگها یا دکترها.
|
||||||
|
|
||||||
|
## Endpoint ها
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/clinicpro/like` | ثبت لایک | بله |
|
||||||
|
| PATCH | `/api/v1/clinicpro/like/{uuid}` | ویرایش/حذف لایک (toggle) | بله |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱، ۰۲
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۲ تا ۳ ساعت
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# معماری — تسک ۱۴: ماژول منشی
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Secretary/
|
||||||
|
├── Controller/
|
||||||
|
│ └── SecretaryController.php
|
||||||
|
├── Service/
|
||||||
|
│ └── SecretaryService.php
|
||||||
|
├── Repository/
|
||||||
|
│ └── SecretaryRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ └── Secretary.php
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ ├── CreateSecretaryRequest.php
|
||||||
|
│ │ └── UpdateSecretaryRequest.php
|
||||||
|
│ └── Response/
|
||||||
|
│ └── SecretaryResponse.php
|
||||||
|
└── Voter/
|
||||||
|
└── SecretaryVoter.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Secretary
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'secretaries')]
|
||||||
|
class Secretary
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
private User $user; // حساب کاربری منشی
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||||
|
private Doctor $doctor; // دکتر مربوطه
|
||||||
|
|
||||||
|
#[ORM\Column(length: 20, default: 'active')]
|
||||||
|
private string $status;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
|
private ?array $permissions; // ['manage_appointments', 'view_payments', ...]
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# پایگاه داده — تسک ۱۴: ماژول منشی
|
||||||
|
|
||||||
|
## جدول: doctor_secretaries
|
||||||
|
_(entity_type=clinic_pro, bundle=doctor_secretary — از config تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | uid | مالک رکورد |
|
||||||
|
| doctor_id | INT FK → doctors.id NOT NULL | field_doctor | دکتر (entity ref → clinic_pro/doctor) |
|
||||||
|
| secretary_id | INT FK → users.id NOT NULL | field_secretary | منشی (entity ref → user) |
|
||||||
|
| telephone | VARCHAR(50) NULL | field_telephone | تلفن تماس منشی |
|
||||||
|
| permission | LONGTEXT NULL | field_permission | مجوزها (JSON یا متن) |
|
||||||
|
| active | TINYINT(1) DEFAULT 1 | field_active | فعال/غیرفعال (نه status VARCHAR!) |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_secretary_doctor_user ON doctor_secretaries(doctor_id, secretary_id);
|
||||||
|
CREATE INDEX idx_secretary_doctor ON doctor_secretaries(doctor_id);
|
||||||
|
CREATE INDEX idx_secretary_user ON doctor_secretaries(secretary_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- فیلد وضعیت: `active` (TINYINT boolean) — نه `status` با مقادیر string
|
||||||
|
- `field_permission` نوع string_long است (LONGTEXT)، میتواند JSON یا متن ساده باشد
|
||||||
|
- `doctor_id` → FK به doctors.id (نه clinic_pro.id) — در Symfony به entity doctor اشاره میکند
|
||||||
|
- `secretary_id` → FK به users.id — کاربری که نقش منشی دارد
|
||||||
|
- UNIQUE(doctor_id, secretary_id): یک منشی نمیتواند دو بار برای یک دکتر ثبت شود
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۱۴: ماژول منشی
|
||||||
|
|
||||||
|
## نقش کاربری
|
||||||
|
هنگام ایجاد secretary، نقش `ROLE_SECRETARY` به user مرتبط اضافه میشود.
|
||||||
|
هنگام حذف، نقش را remove کن (اگر منشی دکتر دیگری نیست).
|
||||||
|
|
||||||
|
## مجوزهای منشی
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"permissions": [
|
||||||
|
"manage_appointments", // مدیریت نوبتها
|
||||||
|
"view_payments", // مشاهده پرداختها
|
||||||
|
"manage_schedule" // مدیریت برنامه
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## مجوزها در سیستم
|
||||||
|
```
|
||||||
|
POST → دکتر (برای خودش) یا ROLE_ADMIN
|
||||||
|
PATCH → دکتر مرتبط یا ROLE_ADMIN
|
||||||
|
DELETE → دکتر مرتبط یا ROLE_ADMIN
|
||||||
|
GET → دکتر مرتبط، خود منشی، یا ROLE_ADMIN
|
||||||
|
GET list → دکتر مرتبط یا ROLE_ADMIN
|
||||||
|
```
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# تسک ۱۴: ماژول منشی
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
مدیریت منشیهای دکترها که میتوانند نوبتها و پرداختها را مدیریت کنند.
|
||||||
|
هر دکتر بسته به پلن اشتراک میتواند ۱ یا ۳ منشی فعال داشته باشد.
|
||||||
|
|
||||||
|
## Endpoint ها
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/secretary` | ایجاد منشی | بله (Doctor/Admin) |
|
||||||
|
| PATCH | `/api/v1/secretary/{uuid}` | ویرایش منشی | بله (Doctor/Admin) |
|
||||||
|
| GET | `/api/v1/secretary/{uuid}` | دریافت اطلاعات منشی | بله |
|
||||||
|
| DELETE | `/api/v1/secretary/{uuid}` | حذف منشی | بله (Doctor/Admin) |
|
||||||
|
| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشیهای دکتر | بله |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱، ۰۲، ۰۵ (Doctor)
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۵ تا ۶ ساعت
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## سیستم مجوزها — Resource-Based Permissions (مقیاسپذیر)
|
||||||
|
|
||||||
|
فیلد `permissions` در جدول `doctor_secretaries` یک JSON ساختاریافته با نسخهبندی است.
|
||||||
|
طراحی به گونهای است که در آینده بتوان منابع (`resources`) و عملیات (`actions`) جدید اضافه کرد بدون تغییر در ساختار جدول.
|
||||||
|
|
||||||
|
### ساختار JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"resources": {
|
||||||
|
"appointments": {
|
||||||
|
"view": true,
|
||||||
|
"create": true,
|
||||||
|
"cancel": false,
|
||||||
|
"update_status": true
|
||||||
|
},
|
||||||
|
"addresses": {
|
||||||
|
"view": true,
|
||||||
|
"create": true,
|
||||||
|
"update": true,
|
||||||
|
"delete": false
|
||||||
|
},
|
||||||
|
"clinic_info": {
|
||||||
|
"view": true,
|
||||||
|
"update": false
|
||||||
|
},
|
||||||
|
"insurances": {
|
||||||
|
"view": true,
|
||||||
|
"create": true,
|
||||||
|
"update": true,
|
||||||
|
"delete": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### منابع و عملیات فعلی
|
||||||
|
|
||||||
|
| Resource | Actions | توضیح |
|
||||||
|
|----------|---------|-------|
|
||||||
|
| `appointments` | `view`, `create`, `cancel`, `update_status` | نوبتها |
|
||||||
|
| `addresses` | `view`, `create`, `update`, `delete` | آدرسهای مطب/کلینیک |
|
||||||
|
| `clinic_info` | `view`, `update` | اطلاعات مطب یا کلینیک |
|
||||||
|
| `insurances` | `view`, `create`, `update`, `delete` | بیمهها |
|
||||||
|
|
||||||
|
### مقیاسپذیری — اضافه کردن Resource جدید در آینده
|
||||||
|
|
||||||
|
برای اضافه کردن Resource جدید (مثلاً `patients` یا `reports`) فقط کافی است:
|
||||||
|
1. کلید جدید به JSON اضافه شود — بدون migration جدید
|
||||||
|
2. کد Permission Checker به صورت خودکار آن را پشتیبانی میکند
|
||||||
|
3. منشیهای موجود که کلید جدید را ندارند، به صورت پیشفرض `false` دارند
|
||||||
|
|
||||||
|
### پیادهسازی PHP — SecretaryPermissionChecker
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Secretary/Security/SecretaryPermissionChecker.php
|
||||||
|
class SecretaryPermissionChecker
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* بررسی مجوز منشی برای یک عملیات روی یک منبع
|
||||||
|
* مثال: $checker->can($secretary, 'appointments', 'create')
|
||||||
|
*/
|
||||||
|
public function can(Secretary $secretary, string $resource, string $action): bool
|
||||||
|
{
|
||||||
|
if (!$secretary->isActive()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = $secretary->getPermissions();
|
||||||
|
return (bool) ($permissions['resources'][$resource][$action] ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* بررسی دسترسی کامل به یک منبع (همه actions باید true باشند)
|
||||||
|
*/
|
||||||
|
public function canAll(Secretary $secretary, string $resource, array $actions): bool
|
||||||
|
{
|
||||||
|
return array_reduce(
|
||||||
|
$actions,
|
||||||
|
fn($carry, $action) => $carry && $this->can($secretary, $resource, $action),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**مثال استفاده در Controller:**
|
||||||
|
```php
|
||||||
|
// در AppointmentController
|
||||||
|
if (!$this->permissionChecker->can($secretary, 'appointments', 'create')) {
|
||||||
|
throw new AccessDeniedHttpException('منشی مجاز به ثبت نوبت نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
// در InsuranceController
|
||||||
|
if (!$this->permissionChecker->can($secretary, 'insurances', 'delete')) {
|
||||||
|
throw new AccessDeniedHttpException('منشی مجاز به حذف بیمه نیست');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### پیشفرض هنگام ایجاد منشی
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"resources": {
|
||||||
|
"appointments": {
|
||||||
|
"view": true,
|
||||||
|
"create": true,
|
||||||
|
"cancel": false,
|
||||||
|
"update_status": true
|
||||||
|
},
|
||||||
|
"addresses": {
|
||||||
|
"view": true,
|
||||||
|
"create": false,
|
||||||
|
"update": false,
|
||||||
|
"delete": false
|
||||||
|
},
|
||||||
|
"clinic_info": {
|
||||||
|
"view": true,
|
||||||
|
"update": false
|
||||||
|
},
|
||||||
|
"insurances": {
|
||||||
|
"view": true,
|
||||||
|
"create": false,
|
||||||
|
"update": false,
|
||||||
|
"delete": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/v1/secretary
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"mobile_number": "09120671756",
|
||||||
|
"doctor_uuid": "61be915b-...",
|
||||||
|
"permissions": {
|
||||||
|
"version": 1,
|
||||||
|
"resources": {
|
||||||
|
"appointments": {
|
||||||
|
"view": true,
|
||||||
|
"create": true,
|
||||||
|
"cancel": false,
|
||||||
|
"update_status": true
|
||||||
|
},
|
||||||
|
"addresses": {
|
||||||
|
"view": true,
|
||||||
|
"create": false,
|
||||||
|
"update": false,
|
||||||
|
"delete": false
|
||||||
|
},
|
||||||
|
"clinic_info": {
|
||||||
|
"view": true,
|
||||||
|
"update": false
|
||||||
|
},
|
||||||
|
"insurances": {
|
||||||
|
"view": true,
|
||||||
|
"create": false,
|
||||||
|
"update": false,
|
||||||
|
"delete": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 201
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "...",
|
||||||
|
"user": { "uuid": "...", "realname": "فاطمه رضایی", "mobile": "09120671756" },
|
||||||
|
"doctor": { "uuid": "...", "name": "دکتر احمدی" },
|
||||||
|
"active": true,
|
||||||
|
"permissions": { ... },
|
||||||
|
"created_at": 1748000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 422 — حد مجاز منشی
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"errors": [{ "code": "ERR_SECRETARY_001", "message": "پلن فعلی اجازه منشی بیشتر را نمیدهد" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**قانون بررسی پلن (سمت سرور):**
|
||||||
|
```
|
||||||
|
پلن بیسیک → max 1 منشی فعال
|
||||||
|
پلن پیشرفته → max 3 منشی فعال
|
||||||
|
|
||||||
|
هنگام POST /secretary:
|
||||||
|
activeCount = COUNT(*) WHERE doctor_id=X AND active=true
|
||||||
|
if activeCount >= maxAllowed → 422
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PATCH /api/v1/secretary/{uuid}
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request (فقط resources موردنظر — deep merge با پیشفرضها)
|
||||||
|
{
|
||||||
|
"active": false,
|
||||||
|
"permissions": {
|
||||||
|
"resources": {
|
||||||
|
"insurances": {
|
||||||
|
"create": true,
|
||||||
|
"update": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// نکته: فقط resources/actions ارسالشده تغییر میکنند — بقیه دستنخورده میمانند
|
||||||
|
```
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/v1/secretary/{uuid}
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "...",
|
||||||
|
"user": {
|
||||||
|
"uuid": "...",
|
||||||
|
"realname": "فاطمه رضایی",
|
||||||
|
"mobile": "09120671756",
|
||||||
|
"picture": null
|
||||||
|
},
|
||||||
|
"doctor": { "uuid": "...", "name": "دکتر احمدی" },
|
||||||
|
"active": true,
|
||||||
|
"permissions": {
|
||||||
|
"version": 1,
|
||||||
|
"resources": {
|
||||||
|
"appointments": { "view": true, "create": true, "cancel": false, "update_status": true },
|
||||||
|
"addresses": { "view": true, "create": false, "update": false, "delete": false },
|
||||||
|
"clinic_info": { "view": true, "update": false },
|
||||||
|
"insurances": { "view": true, "create": false, "update": false, "delete": false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"created_at": 1748000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/v1/secretaries/{doctorUuid}
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"uuid": "...",
|
||||||
|
"user": { "uuid": "...", "realname": "فاطمه رضایی", "mobile": "09120671756" },
|
||||||
|
"active": true,
|
||||||
|
"permissions": { ... },
|
||||||
|
"created_at": 1748000000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **کاربر منشی:** هنگام ایجاد منشی با mobile_number، ابتدا بررسی میشود آیا کاربر با این شماره وجود دارد — اگر نه، کاربر جدید ایجاد میشود
|
||||||
|
- **ROLE:** کاربر منشی باید role `doctor_s_secretary` داشته باشد
|
||||||
|
- **لاگین منشی:** منشی میتواند با username/password لاگین کند (تسک ۰۲)
|
||||||
|
- **بررسی پلن:** کاملاً سمت سرور انجام میشود، قابل دور زدن نیست
|
||||||
|
- **نوبت آفلاین:** نوبتی که منشی ثبت میکند (`appointments.create`) کمیسیون نماینده ندارد
|
||||||
|
- **PATCH permissions:** فقط resources/actions ارسالشده تغییر میکنند (deep merge) — بقیه دستنخورده
|
||||||
|
- **Resource ناشناخته:** اگر resource جدیدی در JSON باشد که سرور نمیشناسد، نادیده گرفته میشود (forward compat)
|
||||||
|
- **پیشفرض `false`:** اگر resource یا action در JSON وجود نداشته باشد → `false` (deny by default)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# معماری — تسک ۱۵: ماژول پرداخت
|
||||||
|
|
||||||
|
## ساختار فایلها
|
||||||
|
```
|
||||||
|
src/Module/Payment/
|
||||||
|
├── Controller/
|
||||||
|
│ ├── PaymentController.php ← ایجاد و دریافت پرداخت
|
||||||
|
│ └── PaymentCallbackController.php ← callback درگاه پرداخت
|
||||||
|
├── Service/
|
||||||
|
│ ├── PaymentService.php
|
||||||
|
│ └── Gateway/
|
||||||
|
│ ├── PaymentGatewayInterface.php
|
||||||
|
│ ├── ZarinpalGateway.php ← درگاه زرینپال
|
||||||
|
│ └── NullGateway.php ← برای محیط dev
|
||||||
|
├── Repository/
|
||||||
|
│ └── PaymentRepository.php
|
||||||
|
├── Entity/
|
||||||
|
│ └── Payment.php
|
||||||
|
├── DTO/
|
||||||
|
│ ├── Request/
|
||||||
|
│ │ └── CreatePaymentRequest.php
|
||||||
|
│ └── Response/
|
||||||
|
│ └── PaymentResponse.php
|
||||||
|
└── Voter/
|
||||||
|
└── PaymentVoter.php
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity: Payment
|
||||||
|
```php
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'payments')]
|
||||||
|
class Payment
|
||||||
|
{
|
||||||
|
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
private Uuid $uuid;
|
||||||
|
|
||||||
|
#[ORM\OneToOne(targetEntity: Appointment::class)]
|
||||||
|
private Appointment $appointment;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
private User $user;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $amount; // ریال
|
||||||
|
|
||||||
|
// pending, paid, failed, refunded
|
||||||
|
#[ORM\Column(length: 20, default: 'pending')]
|
||||||
|
private string $status;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 30, nullable: true)]
|
||||||
|
private ?string $paymentMethod; // online, cash, insurance
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
private ?string $gatewayToken; // توکن درگاه
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
|
private ?string $referenceCode; // کد پیگیری
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $paidAt;
|
||||||
|
|
||||||
|
// TimestampableTrait
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# پایگاه داده — تسک ۱۵: ماژول پرداخت
|
||||||
|
|
||||||
|
## جدول: payments
|
||||||
|
_(entity_type=payment, bundle=appointment — از DB backup تأیید شده)_
|
||||||
|
|
||||||
|
| ستون | نوع | نام Drupal | توضیح |
|
||||||
|
|------|-----|-----------|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | id | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | uuid | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | uid | پرداختکننده |
|
||||||
|
| appointment_id | INT FK → appointments.id UNIQUE NULL | field_reference_id | entity ref → appointment |
|
||||||
|
| representation_id | INT FK → representations.id NULL | field_representation | entity ref → clinic_pro |
|
||||||
|
| amount | INT NOT NULL | field_amount | مبلغ به **ریال** (نه تومان) — تایپ INT |
|
||||||
|
| status | VARCHAR(20) DEFAULT 'pending' | field_status | وضعیت |
|
||||||
|
| payment_method | VARCHAR(20) NULL | field_payment_method | روش پرداخت |
|
||||||
|
| ref_id | VARCHAR(100) NULL | field_ref_id | SaleReferenceId بانک |
|
||||||
|
| frontend_address | VARCHAR(150) NULL | field_frontend_address | URL فرانت برای redirect |
|
||||||
|
| payment_time | INT NULL | field_payment_time | زمان پرداخت (Unix timestamp) |
|
||||||
|
| card_info | LONGTEXT NULL | field_card_info | اطلاعات کارت (JSON/text) |
|
||||||
|
| created_at | INT NOT NULL | created | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | changed | Unix timestamp |
|
||||||
|
|
||||||
|
## وضعیتهای پرداخت (field_status) — از config
|
||||||
|
```
|
||||||
|
pending → ایجاد شده، منتظر پرداخت
|
||||||
|
received → پرداخت موفق تأیید شده ⚠️ نه 'paid'!
|
||||||
|
refund → مبلغ برگشت خورده
|
||||||
|
canceled → لغو شده (نه 'failed')
|
||||||
|
```
|
||||||
|
|
||||||
|
## روشهای پرداخت (field_payment_method) — از config
|
||||||
|
```
|
||||||
|
mellat → بانک ملت (SOAP)
|
||||||
|
sep → بانک سامان (SEP)
|
||||||
|
```
|
||||||
|
|
||||||
|
## نمونه داده واقعی از DB backup
|
||||||
|
```
|
||||||
|
id=2, uid=33, amount=100000 (ریال), status=pending, method=mellat
|
||||||
|
frontend_address='http://yasuj-nobat.localhost:3000/'
|
||||||
|
```
|
||||||
|
|
||||||
|
## ایندکسها
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX idx_payments_appointment ON payments(appointment_id);
|
||||||
|
CREATE INDEX idx_payments_user ON payments(user_id);
|
||||||
|
CREATE INDEX idx_payments_status ON payments(status);
|
||||||
|
CREATE INDEX idx_payments_representation ON payments(representation_id);
|
||||||
|
CREATE INDEX idx_payments_ref_id ON payments(ref_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## وابستگی وضعیت appointment
|
||||||
|
```
|
||||||
|
appointments.status = 'waiting_for_payment' → پیشنیاز ایجاد payment
|
||||||
|
بعد از پرداخت موفق:
|
||||||
|
payments.status = 'received'
|
||||||
|
appointments.status = 'reserved'
|
||||||
|
payment_time = Unix timestamp الان
|
||||||
|
```
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
- `amount` نوع **INT** است (نه DECIMAL) — از DB backup تأیید شد (مثال: 100000)
|
||||||
|
- مبلغ در **ریال** ذخیره میشود
|
||||||
|
- `field_reference_id` → entity reference به appointment (نه foreign key مستقیم در جدول payment)
|
||||||
|
- `frontend_address` برای redirect بعد از پرداخت به سایت نماینده است
|
||||||
|
- `card_info` برای ذخیره اطلاعات کارت بانکی (مثلاً شماره کارت ماسکشده)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## جدول: subscription_payments (پرداخت اشتراک)
|
||||||
|
_(از بخش ۲.۱۰.۲ مستند — نوع پرداخت مجزا از پرداخت نوبت)_
|
||||||
|
|
||||||
|
| ستون | نوع | توضیح |
|
||||||
|
|------|-----|-------|
|
||||||
|
| id | INT UNSIGNED AUTO_INCREMENT PK | |
|
||||||
|
| uuid | CHAR(36) UNIQUE NOT NULL | |
|
||||||
|
| user_id | INT FK → users.id NOT NULL | کاربر سابسکرایبکننده |
|
||||||
|
| reference_type | VARCHAR(10) NOT NULL | `doctor` یا `clinic` |
|
||||||
|
| reference_id | INT NOT NULL | FK به doctors.id یا clinics.id |
|
||||||
|
| representation_id | INT FK → representations.id NULL | نماینده (در صورت وجود) |
|
||||||
|
| amount | INT NOT NULL | مبلغ اشتراک (ریال) |
|
||||||
|
| payment_method | VARCHAR(20) NULL | روش پرداخت (mellat, sep, ...) |
|
||||||
|
| payment_time | INT NULL | زمان پرداخت (Unix timestamp) |
|
||||||
|
| start_date | INT NOT NULL | تاریخ شروع اشتراک (Unix timestamp) |
|
||||||
|
| expiration_date | INT NOT NULL | تاریخ انقضای اشتراک (Unix timestamp) |
|
||||||
|
| ref_id | VARCHAR(100) NULL | شماره مرجع درگاه بانکی |
|
||||||
|
| card_info | LONGTEXT NULL | اطلاعات کارت بانکی (JSON) |
|
||||||
|
| frontend_address | VARCHAR(150) NULL | آدرس بازگشت پس از پرداخت |
|
||||||
|
| status | VARCHAR(20) DEFAULT 'pending' | pending \| received \| refund \| canceled |
|
||||||
|
| created_at | INT NOT NULL | Unix timestamp |
|
||||||
|
| updated_at | INT NOT NULL | Unix timestamp |
|
||||||
|
|
||||||
|
## ایندکسهای subscription_payments
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_sub_payments_user ON subscription_payments(user_id);
|
||||||
|
CREATE INDEX idx_sub_payments_ref ON subscription_payments(reference_type, reference_id);
|
||||||
|
CREATE INDEX idx_sub_payments_status ON subscription_payments(status);
|
||||||
|
CREATE INDEX idx_sub_payments_expiry ON subscription_payments(expiration_date);
|
||||||
|
```
|
||||||
|
|
||||||
|
## تفاوت payments و subscription_payments
|
||||||
|
|
||||||
|
| ویژگی | payments | subscription_payments |
|
||||||
|
|-------|----------|----------------------|
|
||||||
|
| مرجع | `appointment_id` | `reference_id` → doctor/clinic |
|
||||||
|
| فیلدهای اضافه | — | `start_date`, `expiration_date` |
|
||||||
|
| هدف | پرداخت نوبت | خرید اشتراک پلن |
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# نکات پیادهسازی — تسک ۱۵: ماژول پرداخت
|
||||||
|
|
||||||
|
## درگاههای واقعی پروژه (از کد Drupal)
|
||||||
|
|
||||||
|
### ۱. بانک ملت (Mellat) — پروتکل SOAP
|
||||||
|
```php
|
||||||
|
// وبسرویس SOAP با متدهای:
|
||||||
|
// bpPayRequest → شروع تراکنش
|
||||||
|
// bpVerifyRequest → تأیید پرداخت
|
||||||
|
// bpInquiryRequest → استعلام وضعیت
|
||||||
|
// bpSettleRequest → تسویه
|
||||||
|
// bpReversalRequest → برگشت تراکنش
|
||||||
|
|
||||||
|
// پارامترهای پیکربندی:
|
||||||
|
terminal_id, username, password
|
||||||
|
wsdl_endpoint, gate_url
|
||||||
|
test_mode (boolean)
|
||||||
|
callback_url, callback_url_test
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. SEP (سامان) — درگاه دوم
|
||||||
|
در `sep_payment/src/Plugin/MyPayment/SepPayment.php` پیادهسازی شده.
|
||||||
|
|
||||||
|
### پیادهسازی در Symfony
|
||||||
|
```php
|
||||||
|
interface PaymentGatewayInterface {
|
||||||
|
public function pay(array $data): array; // { success, ref_id, gateway_url }
|
||||||
|
public function verify(array $callbackData, int $orderId): array;
|
||||||
|
public function refund(array $data): array;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
پیکربندی در `.env`:
|
||||||
|
```
|
||||||
|
PAYMENT_GATEWAY=mellat # mellat | sep
|
||||||
|
MELLAT_TERMINAL_ID=...
|
||||||
|
MELLAT_USERNAME=...
|
||||||
|
MELLAT_PASSWORD=...
|
||||||
|
MELLAT_TEST_MODE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
## وضعیت پرداخت (از کد واقعی)
|
||||||
|
```
|
||||||
|
pending → بعد از ایجاد پرداخت
|
||||||
|
received → بعد از تأیید موفق (نه "paid"!)
|
||||||
|
failed → پرداخت ناموفق
|
||||||
|
```
|
||||||
|
⚠️ در Drupal status موفق `received` است نه `paid`.
|
||||||
|
|
||||||
|
## شرط ایجاد پرداخت
|
||||||
|
**appointment باید status=`waiting_for_payment` داشته باشد.**
|
||||||
|
اگر status متفاوت باشد → 400 error.
|
||||||
|
|
||||||
|
## قیمت از Config (نه Request)
|
||||||
|
```php
|
||||||
|
// مبلغ از پیکربندی خوانده میشود، نه از request body!
|
||||||
|
$amount = $config->get('payment.settings')['price'];
|
||||||
|
```
|
||||||
|
→ در `.env` یا config:
|
||||||
|
```
|
||||||
|
APPOINTMENT_PRICE=500000
|
||||||
|
```
|
||||||
|
|
||||||
|
## frontend_address
|
||||||
|
پرداخت دارای `frontend_address` است — URL فرانت برای redirect بعد از پرداخت.
|
||||||
|
این به representation مرتبط است (سیستم multi-tenant).
|
||||||
|
|
||||||
|
## Callback Mellat
|
||||||
|
```
|
||||||
|
POST /payment/callback/mellat
|
||||||
|
RefId=...
|
||||||
|
ResCode=0
|
||||||
|
SaleOrderId=...
|
||||||
|
SaleReferenceId=...
|
||||||
|
|
||||||
|
جریان:
|
||||||
|
1. ResCode === '0' باشد
|
||||||
|
2. bpVerifyRequest → اگر موفق نبود → bpInquiryRequest → اگر موفق نبود → bpReversalRequest
|
||||||
|
3. bpSettleRequest (resCode='0' یا '45' = قبلاً تسویه شده)
|
||||||
|
4. appointment.status = confirmed
|
||||||
|
5. payment.status = received
|
||||||
|
6. payment.ref_id = SaleReferenceId
|
||||||
|
```
|
||||||
|
|
||||||
|
## Idempotency
|
||||||
|
اگر callback دوبار بیاید، دوبار process نشود:
|
||||||
|
```php
|
||||||
|
if ($payment->getStatus() === 'received') {
|
||||||
|
return; // قبلاً پردازش شده
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## مجوزها
|
||||||
|
```
|
||||||
|
POST /payment → احراز هویتشده
|
||||||
|
GET /payment/{uuid} → owner یا ROLE_ADMIN یا دکتر مرتبط
|
||||||
|
GET /my-payments → owner
|
||||||
|
GET/POST callback → عمومی (درگاه پرداخت)
|
||||||
|
```
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# تسک ۱۵: ماژول پرداخت
|
||||||
|
|
||||||
|
## توضیح
|
||||||
|
مدیریت پرداخت نوبتها از طریق درگاههای Mellat و SEP،
|
||||||
|
callback پرداخت، refund و مشاهده تاریخچه.
|
||||||
|
|
||||||
|
## Endpoint ها
|
||||||
|
|
||||||
|
| متد | مسیر | توضیح | نیاز به Auth |
|
||||||
|
|-----|------|-------|-------------|
|
||||||
|
| POST | `/api/v1/payment` | شروع فرآیند پرداخت | بله |
|
||||||
|
| GET | `/api/v1/payment/{uuid}` | دریافت اطلاعات پرداخت | بله |
|
||||||
|
| GET | `/api/v1/payment/my-payments/{userId}` | تاریخچه پرداختهای من | بله |
|
||||||
|
| POST | `/api/v1/payment/callback/mellat` | Callback از درگاه ملت | خیر (IP whitelist) |
|
||||||
|
| POST | `/api/v1/payment/callback/sep` | Callback از درگاه سامان | خیر (IP whitelist) |
|
||||||
|
| POST | `/api/v1/subscription-payment` | شروع پرداخت اشتراک | بله |
|
||||||
|
| GET | `/api/v1/subscription-payment/{uuid}` | اطلاعات پرداخت اشتراک | بله |
|
||||||
|
| POST | `/api/v1/subscription-payment/callback/mellat` | Callback اشتراک ملت | خیر |
|
||||||
|
| POST | `/api/v1/subscription-payment/callback/sep` | Callback اشتراک سامان | خیر |
|
||||||
|
|
||||||
|
## پیشنیازها
|
||||||
|
- تسک ۰۱، ۰۲، ۱۰ (Appointment)
|
||||||
|
|
||||||
|
## زمان تخمینی
|
||||||
|
۱۰ تا ۱۲ ساعت
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فلوی کامل پرداخت نوبت
|
||||||
|
|
||||||
|
```
|
||||||
|
۱. POST /api/v1/payment
|
||||||
|
↓
|
||||||
|
۲. بررسی: appointment.status == 'waiting_for_payment' ؟
|
||||||
|
↓ (بله)
|
||||||
|
۳. ایجاد رکورد payment با status=pending
|
||||||
|
↓
|
||||||
|
۴. فراخوانی PaymentGatewayInterface::initiate(amount, callback_url)
|
||||||
|
↓
|
||||||
|
┌──────────────────┬──────────────────┐
|
||||||
|
Mellat (SOAP) SEP (REST)
|
||||||
|
→ bpPayRequest → MerchantSendTransaction
|
||||||
|
→ دریافت RefId → دریافت token
|
||||||
|
↓
|
||||||
|
۵. بازگشت payment_url به کلاینت
|
||||||
|
↓
|
||||||
|
۶. Redirect کاربر به درگاه بانک
|
||||||
|
↓
|
||||||
|
۷. [Callback از بانک]
|
||||||
|
↓
|
||||||
|
۸. POST /api/v1/payment/callback/{gateway}
|
||||||
|
↓
|
||||||
|
۹. تأیید تراکنش با درگاه (VerifyRequest)
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
پرداخت موفق پرداخت ناموفق
|
||||||
|
↓ ↓
|
||||||
|
payments.status=received payments.status=canceled
|
||||||
|
appointments.status=reserved appointments.status=waiting_for_payment
|
||||||
|
واریز کمیسیون نماینده (کاربر میتواند مجدداً تلاش کند)
|
||||||
|
↓
|
||||||
|
Redirect به frontend_address
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Strategy Pattern برای درگاهها
|
||||||
|
|
||||||
|
```php
|
||||||
|
interface PaymentGatewayInterface
|
||||||
|
{
|
||||||
|
public function initiate(int $amount, string $callbackUrl, string $description): GatewayInitResult;
|
||||||
|
public function verify(string $refId, int $amount): GatewayVerifyResult;
|
||||||
|
public function getName(): string; // 'mellat' | 'sep'
|
||||||
|
}
|
||||||
|
|
||||||
|
class MellatGateway implements PaymentGatewayInterface { ... }
|
||||||
|
class SepGateway implements PaymentGatewayInterface { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/v1/payment
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"appointment_uuid": "7b759d2a-...",
|
||||||
|
"payment_method": "mellat",
|
||||||
|
"frontend_address": "https://yasuj-nobat.localhost:3000/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "...",
|
||||||
|
"payment_url": "https://bpm.shaparak.ir/pgwchannel/startpay.mellat?RefId=xxx",
|
||||||
|
"amount": 500000,
|
||||||
|
"status": "pending",
|
||||||
|
"expires_at": 1748001800
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 400 — نوبت در وضعیت نامناسب
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"errors": [{ "code": "ERR_PAYMENT_003", "message": "وضعیت نوبت برای پرداخت مناسب نیست" }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 503 — درگاه در دسترس نیست
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"errors": [{ "code": "ERR_PAYMENT_001", "message": "درگاه پرداخت در حال حاضر در دسترس نیست" }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## POST /api/v1/payment/callback/mellat
|
||||||
|
|
||||||
|
```
|
||||||
|
// form-data از بانک
|
||||||
|
ResCode=0
|
||||||
|
SaleOrderId=...
|
||||||
|
SaleReferenceId=12345678
|
||||||
|
```
|
||||||
|
|
||||||
|
**منطق:**
|
||||||
|
```
|
||||||
|
1. پیدا کردن payment با ref_id مربوطه
|
||||||
|
2. فراخوانی MellatGateway::verify(SaleReferenceId, amount)
|
||||||
|
3. اگر موفق:
|
||||||
|
- payments.status = 'received'
|
||||||
|
- payments.ref_id = SaleReferenceId
|
||||||
|
- payments.payment_time = now()
|
||||||
|
- appointments.status = 'reserved'
|
||||||
|
- محاسبه و واریز کمیسیون نماینده (async)
|
||||||
|
4. Redirect به frontend_address + ?status=success
|
||||||
|
5. اگر ناموفق:
|
||||||
|
- payments.status = 'canceled'
|
||||||
|
- Redirect به frontend_address + ?status=failed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## GET /api/v1/payment/{uuid}
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "...",
|
||||||
|
"appointment": {
|
||||||
|
"uuid": "...",
|
||||||
|
"date": "2024-03-20",
|
||||||
|
"time": "09:00",
|
||||||
|
"doctor": { "name": "دکتر احمدی" }
|
||||||
|
},
|
||||||
|
"amount": 500000,
|
||||||
|
"status": "received",
|
||||||
|
"payment_method": "mellat",
|
||||||
|
"ref_id": "12345678",
|
||||||
|
"payment_time": 1748000000,
|
||||||
|
"created_at": 1748000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## فلوی Refund (لغو نوبت بعد از پرداخت)
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH /api/v1/appointment/{uuid}/cancel
|
||||||
|
↓
|
||||||
|
appointment.status = 'cancelled_by_patient'
|
||||||
|
↓
|
||||||
|
payment.status = 'refund'
|
||||||
|
↓
|
||||||
|
ثبت در سیستم — refund واقعی دستی توسط ادمین انجام میشود
|
||||||
|
↓
|
||||||
|
log در سیستم برای پیگیری ادمین
|
||||||
|
```
|
||||||
|
|
||||||
|
> **نکته:** Refund خودکار از درگاه در این پروژه پیادهسازی نمیشود — ادمین به صورت دستی مبلغ را برمیگرداند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subscription Payment — POST /api/v1/subscription-payment
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{
|
||||||
|
"reference_type": "doctor",
|
||||||
|
"reference_id": 29,
|
||||||
|
"plan": "advanced",
|
||||||
|
"payment_method": "mellat",
|
||||||
|
"frontend_address": "https://yasuj-nobat.localhost:3000/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response 200
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"uuid": "...",
|
||||||
|
"payment_url": "https://bpm.shaparak.ir/...",
|
||||||
|
"amount": 5000000,
|
||||||
|
"plan": "advanced",
|
||||||
|
"status": "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**بعد از تأیید پرداخت اشتراک:**
|
||||||
|
```
|
||||||
|
subscription_payments.status = 'received'
|
||||||
|
subscription_payments.start_date = now()
|
||||||
|
subscription_payments.expiration_date = now() + 30 روز (یا 365 روز)
|
||||||
|
واریز کمیسیون به کیف پول نماینده (اگر از طریق نماینده)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **مبلغ در ریال ذخیره میشود** (نه تومان) — مثال: ۵۰,۰۰۰ تومان = ۵۰۰,۰۰۰ ریال
|
||||||
|
- **وضعیت 'received'** — نه 'paid' (مستقیم از Drupal)
|
||||||
|
- **Circuit Breaker:** اگر درگاه ۳ بار پشت سر هم fail داشت → به مدت ۵ دقیقه blocked شود
|
||||||
|
- **Idempotency:** Callback ممکن است چند بار فراخوانی شود — بررسی کنید payment قبلاً verified نشده باشد
|
||||||
|
- **IP Whitelist:** Callback endpoint ها باید فقط از IP های بانک قابل دسترس باشند
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠ امنیت: جلوگیری از Open Redirect
|
||||||
|
|
||||||
|
فیلد `frontend_address` در request میتواند توسط مهاجم دستکاری شود تا Callback به یک سایت مخرب redirect کند.
|
||||||
|
|
||||||
|
**راهحل — Whitelist دامنههای مجاز:**
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/packages/payment.yaml (یا .env)
|
||||||
|
ALLOWED_FRONTEND_HOSTS=yasuj-nobat.localhost,clinicpro.ir,app.clinicpro.ir
|
||||||
|
|
||||||
|
// در PaymentService قبل از ذخیره frontend_address:
|
||||||
|
private function validateFrontendAddress(string $url): void
|
||||||
|
{
|
||||||
|
$parsed = parse_url($url);
|
||||||
|
$host = $parsed['host'] ?? '';
|
||||||
|
$allowed = explode(',', $this->params->get('allowed_frontend_hosts'));
|
||||||
|
|
||||||
|
if (!in_array($host, $allowed, true)) {
|
||||||
|
throw new \InvalidArgumentException('آدرس بازگشت مجاز نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**یا روش سادهتر:** `frontend_address` را از JWT کاربر یا از `representations.domain_name` بخوان — نه از request body.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠ امنیت: IP Whitelist برای Callback
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Payment/EventSubscriber/PaymentCallbackGuard.php
|
||||||
|
class PaymentCallbackGuard implements EventSubscriberInterface
|
||||||
|
{
|
||||||
|
private const MELLAT_IPS = ['185.143.233.0/24', '79.175.148.0/24'];
|
||||||
|
private const SEP_IPS = ['195.146.48.0/24'];
|
||||||
|
|
||||||
|
public function onKernelRequest(RequestEvent $event): void
|
||||||
|
{
|
||||||
|
$path = $event->getRequest()->getPathInfo();
|
||||||
|
if (!str_contains($path, '/payment/callback/')) return;
|
||||||
|
|
||||||
|
$clientIp = $event->getRequest()->getClientIp();
|
||||||
|
$gateway = str_contains($path, 'mellat') ? 'mellat' : 'sep';
|
||||||
|
$allowed = $gateway === 'mellat' ? self::MELLAT_IPS : self::SEP_IPS;
|
||||||
|
|
||||||
|
if (!$this->ipInRanges($clientIp, $allowed)) {
|
||||||
|
throw new AccessDeniedHttpException('IP not allowed for payment callback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user