Add comprehensive project documentation for ClinicPro in CLAUDE.md and README.md

- Introduced CLAUDE.md for internal guidance on project structure, commands, and architecture.
- Created README.md with detailed project overview, technology stack, directory structure, setup instructions, API endpoints, authentication flow, and external services.
This commit is contained in:
hamed
2026-06-10 11:12:15 +03:30
parent c7ae591e49
commit e9075e8c92
18 changed files with 3533 additions and 142 deletions
+486
View File
@@ -17,11 +17,13 @@ use App\Sms\Entity\SmsLog;
use App\Sms\Entity\SmsTemplate;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Admin')]
#[IsGranted('ROLE_ADMIN')]
class AdminApiController extends BaseController
{
@@ -31,6 +33,46 @@ class AdminApiController extends BaseController
// ── Users ─────────────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/users',
summary: 'List all users (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of users',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'id', type: 'integer'),
new OA\Property(property: 'mobile_number', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'email', type: 'string'),
new OA\Property(property: 'roles', type: 'array', items: new OA\Items(type: 'string')),
new OA\Property(property: 'is_active', type: 'boolean'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'meta', properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/users', methods: ['GET'])]
public function users(Request $request): JsonResponse
{
@@ -69,6 +111,48 @@ class AdminApiController extends BaseController
// ── Appointments ──────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/appointments',
summary: 'List all appointments (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of appointments',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'patient_name', type: 'string'),
new OA\Property(property: 'patient_mobile', type: 'string'),
new OA\Property(property: 'doctor_name', type: 'string'),
new OA\Property(property: 'appointment_date', type: 'string', format: 'date'),
new OA\Property(property: 'appointment_time', type: 'string', example: '14:30'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'amount', type: 'integer'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'meta', properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/appointments', methods: ['GET'])]
public function appointments(Request $request): JsonResponse
{
@@ -118,6 +202,46 @@ class AdminApiController extends BaseController
// ── Payments ──────────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/payments',
summary: 'List all payments (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'success', 'failed'])),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of payments',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'amount', type: 'integer'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'gateway', type: 'string'),
new OA\Property(property: 'ref_id', type: 'string', nullable: true),
new OA\Property(property: 'patient_mobile', type: 'string'),
new OA\Property(property: 'paid_at', type: 'string', format: 'date-time', nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'meta', properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/payments', methods: ['GET'])]
public function payments(Request $request): JsonResponse
{
@@ -164,6 +288,49 @@ class AdminApiController extends BaseController
// ── Representations ───────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/representations',
summary: 'List all representations (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'city_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of representations',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'domain', type: 'string'),
new OA\Property(property: 'full_name', type: 'string'),
new OA\Property(property: 'mobile_number', type: 'string'),
new OA\Property(property: 'city_id', type: 'integer', nullable: true),
new OA\Property(property: 'city', type: 'string', nullable: true),
new OA\Property(property: 'commission_percent', type: 'number', format: 'float'),
new OA\Property(property: 'wallet_balance', type: 'integer'),
new OA\Property(property: 'is_active', type: 'boolean'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'meta', properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/representations', methods: ['GET'])]
public function representations(Request $request): JsonResponse
{
@@ -211,6 +378,46 @@ class AdminApiController extends BaseController
// ── Secretaries ───────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/secretaries',
summary: 'List all secretaries (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of secretaries',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'user_name', type: 'string'),
new OA\Property(property: 'mobile_number', type: 'string'),
new OA\Property(property: 'doctor_name', type: 'string'),
new OA\Property(property: 'doctor_uuid', type: 'string'),
new OA\Property(property: 'is_active', type: 'boolean'),
new OA\Property(property: 'permissions', type: 'array', items: new OA\Items(type: 'string')),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'meta', properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/secretaries', methods: ['GET'])]
public function secretaries(Request $request): JsonResponse
{
@@ -255,6 +462,44 @@ class AdminApiController extends BaseController
// ── Ratings ───────────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/rates',
summary: 'List all ratings (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of ratings',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'patient_name', type: 'string'),
new OA\Property(property: 'doctor_name', type: 'string'),
new OA\Property(property: 'overall', type: 'integer'),
new OA\Property(property: 'score', type: 'integer'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'meta', properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/rates', methods: ['GET'])]
public function rates(Request $request): JsonResponse
{
@@ -297,6 +542,47 @@ class AdminApiController extends BaseController
// ── Comments ──────────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/comments',
summary: 'List all comments (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'approved', 'rejected'])),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of comments',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'patient_name', type: 'string'),
new OA\Property(property: 'doctor_name', type: 'string'),
new OA\Property(property: 'title', type: 'string'),
new OA\Property(property: 'body', type: 'string'),
new OA\Property(property: 'is_approved', type: 'boolean'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'meta', properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/comments', methods: ['GET'])]
public function comments(Request $request): JsonResponse
{
@@ -345,6 +631,44 @@ class AdminApiController extends BaseController
// ── SMS Logs ──────────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/sms/logs',
summary: 'List SMS send logs (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of SMS logs',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'recipient', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
new OA\Property(property: 'status', type: 'string', enum: ['sent', 'failed']),
new OA\Property(property: 'provider', type: 'string'),
new OA\Property(property: 'sent_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'meta', properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/sms/logs', methods: ['GET'])]
public function smsLogs(Request $request): JsonResponse
{
@@ -376,6 +700,47 @@ class AdminApiController extends BaseController
// ── Settlements ───────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/settlements',
summary: 'List all settlement requests (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'approved', 'rejected'])),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of settlement requests',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'representation_name', type: 'string'),
new OA\Property(property: 'amount', type: 'integer'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'bank_card', type: 'string', nullable: true),
new OA\Property(property: 'bank_name', type: 'string', nullable: true),
new OA\Property(property: 'reject_reason', type: 'string', nullable: true),
new OA\Property(property: 'requested_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'processed_at', type: 'string', format: 'date-time', nullable: true),
]
)),
new OA\Property(property: 'meta', properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/settlements', methods: ['GET'])]
public function settlements(Request $request): JsonResponse
{
@@ -418,6 +783,45 @@ class AdminApiController extends BaseController
// ── SMS Templates (paginated list with optional status filter) ────────────
#[OA\Get(
path: '/api/v1/admin/sms/sample-templates',
summary: 'List SMS sample templates (paginated)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)),
new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['approved', 'pending'], default: 'approved')),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of SMS templates',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'body', type: 'string'),
new OA\Property(property: 'provider_code', type: 'string', nullable: true),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'admin_note', type: 'string', nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'meta', properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/sms/sample-templates', methods: ['GET'])]
public function smsSampleTemplates(Request $request): JsonResponse
{
@@ -454,6 +858,57 @@ class AdminApiController extends BaseController
// ── Dashboard Recent ──────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/dashboard/recent',
summary: 'Get recent dashboard data (appointments, payments, users)',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(
response: 200,
description: 'Recent dashboard data',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'appointments', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'slot_start', type: 'string', format: 'date-time'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'doctor_name', type: 'string'),
new OA\Property(property: 'user_mobile', type: 'string'),
new OA\Property(property: 'user_name', type: 'string'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'payments', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'amount', type: 'integer'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'gateway', type: 'string'),
new OA\Property(property: 'user_mobile', type: 'string'),
new OA\Property(property: 'user_name', type: 'string'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
new OA\Property(property: 'users', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'mobile', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'email', type: 'string', nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
]
)),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/dashboard/recent', methods: ['GET'])]
public function dashboardRecent(): JsonResponse
{
@@ -521,6 +976,37 @@ class AdminApiController extends BaseController
// ── Dashboard Stats ───────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/dashboard/stats',
summary: 'Get aggregated dashboard statistics',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(
response: 200,
description: 'Dashboard statistics',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'total_users', type: 'integer'),
new OA\Property(property: 'active_doctors', type: 'integer'),
new OA\Property(property: 'total_doctors', type: 'integer'),
new OA\Property(property: 'total_clinics', type: 'integer'),
new OA\Property(property: 'today_appointments', type: 'integer'),
new OA\Property(property: 'total_appointments', type: 'integer'),
new OA\Property(property: 'today_payments_count', type: 'integer'),
new OA\Property(property: 'today_payments_amount', type: 'integer'),
new OA\Property(property: 'total_payments_amount', type: 'integer'),
new OA\Property(property: 'pending_comments', type: 'integer'),
new OA\Property(property: 'pending_settlements', type: 'integer'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[Route('/api/v1/admin/dashboard/stats', methods: ['GET'])]
public function dashboardStats(): JsonResponse
{
@@ -10,12 +10,14 @@ use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\OptimisticLockException;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Appointments')]
class AppointmentController extends BaseController
{
public function __construct(
@@ -26,6 +28,78 @@ class AppointmentController extends BaseController
// ── Public: available slots ───────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/appointment-slots',
summary: 'Get available appointment slots for a doctor on a given date',
parameters: [
new OA\Parameter(
name: 'doctor_uuid',
in: 'query',
required: true,
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'date',
in: 'query',
required: true,
description: 'Date in Y-m-d format',
schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Available slots returned',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'date', type: 'string', format: 'date'),
new OA\Property(
property: 'slots',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'start', type: 'integer', description: 'Unix timestamp'),
new OA\Property(property: 'end', type: 'integer', description: 'Unix timestamp'),
new OA\Property(property: 'available', type: 'boolean'),
],
type: 'object'
)
),
],
type: 'object'
),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
]
)
),
new OA\Response(
response: 404,
description: 'Doctor not found',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
)
),
]
)
),
new OA\Response(response: 422, description: 'Invalid date format'),
]
)]
#[Route('/api/v1/appointment-slots', methods: ['GET'])]
public function slots(Request $request): JsonResponse
{
@@ -52,6 +126,40 @@ class AppointmentController extends BaseController
// ── Authenticated: book / manage ─────────────────────────────────────────
#[OA\Post(
path: '/api/v1/appointment',
summary: 'Book a new appointment',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['doctor_uuid', 'slot_start', 'slot_end'],
properties: [
new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'slot_start', type: 'integer', description: 'Slot start Unix timestamp'),
new OA\Property(property: 'slot_end', type: 'integer', description: 'Slot end Unix timestamp'),
new OA\Property(property: 'note', type: 'string'),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Appointment booked successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Appointment object'),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 404, description: 'Doctor not found'),
new OA\Response(response: 409, description: 'Slot already taken'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/appointment', methods: ['POST'])]
public function book(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -82,6 +190,35 @@ class AppointmentController extends BaseController
return $this->success(['data' => $appointment->toArray()], 201);
}
#[OA\Get(
path: '/api/v1/appointment/{uuid}',
summary: 'Get a single appointment by UUID',
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: 'Appointment returned',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Appointment object'),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 403, description: 'Access denied'),
new OA\Response(response: 404, description: 'Appointment not found'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/appointment/{uuid}', methods: ['GET'])]
public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
@@ -98,6 +235,46 @@ class AppointmentController extends BaseController
return $this->success(['data' => $appointment->toArray()]);
}
#[OA\Get(
path: '/api/v1/appointments/doctor/{doctorUuid}',
summary: 'List appointments for a specific doctor',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(
name: 'doctorUuid',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string', format: 'uuid')
),
new OA\Parameter(
name: 'status',
in: 'query',
required: false,
description: 'Filter by appointment status',
schema: new OA\Schema(type: 'string', example: 'pending')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Appointment list returned',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
type: 'array',
items: new OA\Items(type: 'object', description: 'Appointment object')
),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 403, description: 'Access denied'),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/appointments/doctor/{doctorUuid}', methods: ['GET'])]
public function listByDoctor(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse
@@ -117,6 +294,38 @@ class AppointmentController extends BaseController
return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]);
}
#[OA\Get(
path: '/api/v1/appointments/user',
summary: 'List appointments for the authenticated user',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(
name: 'status',
in: 'query',
required: false,
description: 'Filter by appointment status',
schema: new OA\Schema(type: 'string', example: 'pending')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Appointment list returned',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
type: 'array',
items: new OA\Items(type: 'object', description: 'Appointment object')
),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/appointments/user', methods: ['GET'])]
public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -141,6 +350,47 @@ class AppointmentController extends BaseController
|| $user->hasRole('ROLE_ADMIN');
}
#[OA\Patch(
path: '/api/v1/appointment/{uuid}/status',
summary: 'Update the status of an appointment',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['status'],
properties: [
new OA\Property(property: 'status', type: 'string', example: 'confirmed'),
new OA\Property(property: 'version', type: 'integer', description: 'Optimistic lock version'),
]
)
),
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: 'Appointment status updated',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Updated appointment object'),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
]
)
),
new OA\Response(response: 401, description: 'Unauthenticated'),
new OA\Response(response: 403, description: 'Access denied'),
new OA\Response(response: 404, description: 'Appointment not found'),
new OA\Response(response: 409, description: 'Optimistic lock conflict'),
new OA\Response(response: 422, description: 'Invalid status transition'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])]
public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
+378
View File
@@ -8,12 +8,14 @@ use App\Auth\Service\OtpService;
use App\Auth\Service\TokenService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
#[OA\Tag(name: 'Auth')]
class AuthController extends BaseController
{
public function __construct(
@@ -27,12 +29,110 @@ class AuthController extends BaseController
* Route exists so the router resolves it; PasswordAuthenticator intercepts
* and returns the JWT response before this controller body ever runs.
*/
#[OA\Post(
path: '/api/v1/user/login',
summary: 'Staff login (Admin / Doctor / Clinic / Secretary)',
description: 'ورود با شماره موبایل و رمز عبور — فقط برای کاربران دارای نقش ROLE_ADMIN، ROLE_DOCTOR، ROLE_CLINIC یا ROLE_SECRETARY. کاربران عادی باید از OTP استفاده کنند.',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['mobile_number', 'password'],
properties: [
new OA\Property(property: 'mobile_number', type: 'string', example: '09120671713', description: 'شماره موبایل ثبت‌شده'),
new OA\Property(property: 'password', type: 'string', format: 'password', example: 'admin1234', description: 'رمز عبور'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'ورود موفق — JWT و refresh token برگردانده می‌شود',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'access_token', type: 'string', description: 'JWT — عمر ۱ ساعت'),
new OA\Property(property: 'refresh_token', type: 'string', description: 'Refresh token — عمر ۳۰ روز'),
new OA\Property(property: 'token_type', type: 'string', example: 'Bearer'),
new OA\Property(property: 'expires_in', type: 'integer', example: 3600),
new OA\Property(property: 'refresh_token_expires_in', type: 'integer', example: 2592000),
]
)
),
new OA\Response(
response: 401,
description: 'اطلاعات ورود نادرست',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string', example: 'ERR_AUTH_005'),
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
)
),
]
)
),
new OA\Response(response: 403, description: 'کاربر نقش staff ندارد (ROLE_ADMIN/ROLE_DOCTOR/ROLE_CLINIC/ROLE_SECRETARY)'),
new OA\Response(response: 429, description: 'تعداد تلاش‌های ورود از حد مجاز گذشت'),
]
)]
#[Route('/api/v1/user/login', methods: ['POST'])]
public function login(): JsonResponse
{
return $this->error(ErrorCodes::ERR_AUTH_005, ErrorCodes::message(ErrorCodes::ERR_AUTH_005), 401);
}
#[OA\Post(
path: '/api/v1/user/send-code',
summary: 'Send OTP code to mobile number',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['mobile'],
properties: [
new OA\Property(property: 'mobile', type: 'string', example: '09123456789'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'OTP sent successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'message', type: 'string', example: 'کد تایید با موفقیت ارسال شد.'),
]
)
),
new OA\Response(
response: 422,
description: 'Invalid mobile format',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
)
),
]
)
),
new OA\Response(response: 429, description: 'Rate limit exceeded'),
]
)]
#[Route('/api/v1/user/send-code', methods: ['POST'])]
public function sendCode(Request $request): JsonResponse
{
@@ -53,6 +153,59 @@ class AuthController extends BaseController
return new JsonResponse(['uuid' => $uuid, 'message' => 'کد تایید با موفقیت ارسال شد.']);
}
#[OA\Post(
path: '/api/v1/user/verify-code',
summary: 'Verify OTP code',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['uuid', 'code'],
properties: [
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'code', type: 'string', example: '12345'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Code verified successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'کد با موفقیت تایید شد.'),
],
type: 'object'
),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
]
)
),
new OA\Response(
response: 422,
description: 'Missing or invalid uuid/code',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
)
),
]
)
),
]
)]
#[Route('/api/v1/user/verify-code', methods: ['POST'])]
public function verifyCode(Request $request): JsonResponse
{
@@ -69,6 +222,60 @@ class AuthController extends BaseController
return $this->success(['message' => 'کد با موفقیت تایید شد.']);
}
#[OA\Post(
path: '/api/v1/user/register',
summary: 'Register a new user after OTP verification',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['uuid'],
properties: [
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'real_name', type: 'string', example: 'علی محمدی'),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'User registered successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'ثبت‌نام با موفقیت انجام شد.'),
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
],
type: 'object'
),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
]
)
),
new OA\Response(
response: 422,
description: 'Missing uuid',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
)
),
]
)
),
]
)]
#[Route('/api/v1/user/register', methods: ['POST'])]
public function register(Request $request): JsonResponse
{
@@ -94,6 +301,52 @@ class AuthController extends BaseController
return $this->success(['message' => 'ثبت‌نام با موفقیت انجام شد.', 'uuid' => $user->getUuid()], 201);
}
#[OA\Post(
path: '/oauth/token',
summary: 'Issue access and refresh tokens using OTP-verified UUID',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['grant_type', 'uuid'],
properties: [
new OA\Property(property: 'grant_type', type: 'string', enum: ['mobile'], example: 'mobile'),
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Tokens issued successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'token', type: 'string'),
new OA\Property(property: 'refresh_token', type: 'string'),
]
)
),
new OA\Response(
response: 400,
description: 'Invalid grant_type',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
)
),
]
)
),
]
)]
#[Route('/oauth/token', methods: ['POST'])]
public function issueToken(Request $request): JsonResponse
{
@@ -115,6 +368,51 @@ class AuthController extends BaseController
return new JsonResponse($this->tokenService->issueTokens($user));
}
#[OA\Post(
path: '/oauth/token/refresh',
summary: 'Refresh access token using a refresh token',
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['refresh_token'],
properties: [
new OA\Property(property: 'refresh_token', type: 'string'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Token refreshed successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'token', type: 'string'),
new OA\Property(property: 'refresh_token', type: 'string'),
]
)
),
new OA\Response(
response: 401,
description: 'Invalid or missing refresh token',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
)
),
]
)
),
]
)]
#[Route('/oauth/token/refresh', methods: ['POST'])]
public function refreshToken(Request $request): JsonResponse
{
@@ -138,6 +436,55 @@ class AuthController extends BaseController
return new JsonResponse($tokens);
}
#[OA\Get(
path: '/oauth/userinfo',
summary: 'Get current authenticated user info',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(
response: 200,
description: 'User info returned successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'id', type: 'integer'),
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'mobile_number', type: 'string'),
new OA\Property(property: 'realName', type: 'string'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'roles', type: 'array', items: new OA\Items(type: 'string')),
],
type: 'object'
),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
]
)
),
new OA\Response(
response: 401,
description: 'Unauthenticated',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
)
),
]
)
),
]
)]
#[Route('/oauth/userinfo', methods: ['GET'])]
public function userInfo(#[CurrentUser] ?User $user): JsonResponse
{
@@ -155,6 +502,37 @@ class AuthController extends BaseController
]);
}
#[OA\Post(
path: '/oauth/logout',
summary: 'Logout and optionally revoke refresh token',
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'refresh_token', type: 'string'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Logged out successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'خروج با موفقیت انجام شد'),
],
type: 'object'
),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items()),
]
)
),
]
)]
#[Route('/oauth/logout', methods: ['POST'])]
public function logout(Request $request): JsonResponse
{
+258
View File
@@ -8,12 +8,14 @@ use App\Blog\Repository\BlogRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Blog')]
class BlogController extends BaseController
{
public function __construct(
@@ -24,6 +26,35 @@ class BlogController extends BaseController
// ── Public list/detail ────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/blogs',
summary: 'List published blog posts (paginated)',
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20, maximum: 50)),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of published blog posts',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(
property: 'meta',
properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
],
type: 'object'
),
]
)
),
]
)]
#[Route('/api/v1/blogs', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
@@ -36,6 +67,38 @@ class BlogController extends BaseController
return $this->paginated($blogs, $total, $page, $limit);
}
#[OA\Get(
path: '/api/v1/blog/{slug}',
summary: 'Get a published blog post by slug or UUID',
parameters: [
new OA\Parameter(
name: 'slug',
in: 'path',
required: true,
description: 'Blog slug or UUID',
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Blog post detail',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'data', type: 'object'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 404, description: 'Blog post not found or not published'),
]
)]
#[Route('/api/v1/blog/{slug}', methods: ['GET'])]
public function detail(string $slug): JsonResponse
{
@@ -49,6 +112,63 @@ class BlogController extends BaseController
// ── Admin CRUD ────────────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/blog',
summary: 'Create a new blog post (admin only)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['title', 'body'],
properties: [
new OA\Property(property: 'title', type: 'string'),
new OA\Property(property: 'body', type: 'string'),
new OA\Property(property: 'summary', type: 'string', nullable: true),
new OA\Property(property: 'tags', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: 'status', type: 'string', enum: ['draft', 'published'], nullable: true),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Blog post created',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'data', type: 'object'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(
response: 422,
description: 'Validation error',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/blog', methods: ['POST'])]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -76,6 +196,52 @@ class BlogController extends BaseController
return $this->success(['data' => $blog->toArray()], 201);
}
#[OA\Patch(
path: '/api/v1/blog/{uuid}',
summary: 'Update a blog post (admin only)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'title', type: 'string', nullable: true),
new OA\Property(property: 'body', type: 'string', nullable: true),
new OA\Property(property: 'summary', type: 'string', nullable: true),
new OA\Property(property: 'tags', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: 'status', type: 'string', enum: ['draft', 'published'], nullable: true),
]
)
),
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: 'Blog post updated',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'data', type: 'object'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(response: 404, description: 'Blog post not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/blog/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request): JsonResponse
@@ -97,6 +263,40 @@ class BlogController extends BaseController
return $this->success(['data' => $blog->toArray()]);
}
#[OA\Delete(
path: '/api/v1/blog/{uuid}',
summary: 'Delete a blog post (admin only)',
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: 'Blog post deleted',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'message', type: 'string'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(response: 404, description: 'Blog post not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/blog/{uuid}', methods: ['DELETE'])]
public function delete(string $uuid): JsonResponse
@@ -112,6 +312,64 @@ class BlogController extends BaseController
// ── Image upload ──────────────────────────────────────────────────────────
#[OA\Post(
path: '/file/upload/clinic_pro/blog/field_image',
summary: 'Upload a blog post image (admin only)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\MediaType(
mediaType: 'multipart/form-data',
schema: new OA\Schema(
required: ['file'],
properties: [
new OA\Property(property: 'file', type: 'string', format: 'binary'),
new OA\Property(property: 'blog_uuid', type: 'string', format: 'uuid', nullable: true),
]
)
)
),
responses: [
new OA\Response(
response: 200,
description: 'Image uploaded',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'image_url', type: 'string'),
new OA\Property(property: 'filename', type: 'string'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
new OA\Response(
response: 422,
description: 'No file provided or invalid file',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/file/upload/clinic_pro/blog/field_image', methods: ['POST'])]
public function uploadImage(Request $request): JsonResponse
+255
View File
@@ -12,6 +12,7 @@ use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
@@ -19,6 +20,7 @@ use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
#[OA\Tag(name: 'Clinics')]
class ClinicController extends BaseController
{
public function __construct(
@@ -30,6 +32,47 @@ class ClinicController extends BaseController
private readonly string $projectDir,
) {}
#[OA\Post(
path: '/api/v1/clinic',
summary: 'Create a new clinic',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'address', type: 'string', nullable: true),
new OA\Property(property: 'telephone', type: 'string', nullable: true),
new OA\Property(property: 'working_days', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: '24_7', type: 'boolean', nullable: true),
new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true),
new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true),
new OA\Property(property: 'state', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'city', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'image_clinic', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: 'clinic_logo', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: 'doctors', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'doctor_services', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'insurance', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Clinic created',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
]
)]
#[Route('/api/v1/clinic', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -50,6 +93,31 @@ class ClinicController extends BaseController
return $this->success(['data' => $clinic->toDetailArray()], 201);
}
#[OA\Get(
path: '/api/v1/clinic/{uuid}',
summary: 'Get clinic details by UUID',
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: 'Clinic details',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 404, description: 'Clinic not found'),
]
)]
#[Route('/api/v1/clinic/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
{
@@ -63,6 +131,57 @@ class ClinicController extends BaseController
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData)]);
}
#[OA\Patch(
path: '/api/v1/clinic/{uuid}',
summary: 'Update clinic details',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'name', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(property: 'address', type: 'string', nullable: true),
new OA\Property(property: 'telephone', type: 'string', nullable: true),
new OA\Property(property: 'working_days', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: '24_7', type: 'boolean', nullable: true),
new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true),
new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true),
new OA\Property(property: 'state', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'city', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'image_clinic', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: 'clinic_logo', type: 'array', items: new OA\Items(type: 'string'), nullable: true),
new OA\Property(property: 'doctors', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'doctor_services', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
new OA\Property(property: 'insurance', type: 'array', items: new OA\Items(type: 'integer'), nullable: true),
]
)
),
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: 'Clinic updated',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Clinic not found'),
]
)]
#[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
@@ -85,6 +204,37 @@ class ClinicController extends BaseController
return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData)]);
}
#[OA\Get(
path: '/api/v1/clinics',
summary: 'List clinics with optional filters (paginated)',
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20)),
new OA\Parameter(name: 'name', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'city', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated list of clinics',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(
property: 'meta',
properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
],
type: 'object'
),
]
)
),
]
)]
#[Route('/api/v1/clinics', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
@@ -99,6 +249,37 @@ class ClinicController extends BaseController
);
}
#[OA\Get(
path: '/api/v1/clinic/doctor-list/{clinicUuid}',
summary: 'Get list of doctors for a clinic',
parameters: [
new OA\Parameter(
name: 'clinicUuid',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string', format: 'uuid')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of doctors',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
],
type: 'object'
),
]
)
),
new OA\Response(response: 404, description: 'Clinic not found'),
]
)]
#[Route('/api/v1/clinic/doctor-list/{clinicUuid}', methods: ['GET'])]
public function doctorList(string $clinicUuid): JsonResponse
{
@@ -115,6 +296,43 @@ class ClinicController extends BaseController
return $this->success(['data' => $doctors]);
}
#[OA\Post(
path: '/file/upload/clinic_pro/clinic/field_image_clinic',
summary: 'Upload a clinic gallery image',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\MediaType(
mediaType: 'application/octet-stream',
schema: new OA\Schema(type: 'string', format: 'binary')
)
),
responses: [
new OA\Response(
response: 200,
description: 'Image uploaded',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'fid', type: 'integer'),
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'url', type: 'string'),
new OA\Property(property: 'filename', type: 'string'),
new OA\Property(property: 'filemime', type: 'string'),
new OA\Property(property: 'filesize', type: 'integer'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 422, description: 'Invalid file'),
]
)]
#[Route('/file/upload/clinic_pro/clinic/field_image_clinic', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function uploadImage(Request $request): JsonResponse
@@ -122,6 +340,43 @@ class ClinicController extends BaseController
return $this->handleFileUpload($request, 'clinics/gallery');
}
#[OA\Post(
path: '/file/upload/clinic_pro/clinic/field_clinic_logo',
summary: 'Upload a clinic logo image',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\MediaType(
mediaType: 'application/octet-stream',
schema: new OA\Schema(type: 'string', format: 'binary')
)
),
responses: [
new OA\Response(
response: 200,
description: 'Logo uploaded',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'fid', type: 'integer'),
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'url', type: 'string'),
new OA\Property(property: 'filename', type: 'string'),
new OA\Property(property: 'filemime', type: 'string'),
new OA\Property(property: 'filesize', type: 'integer'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 422, description: 'Invalid file'),
]
)]
#[Route('/file/upload/clinic_pro/clinic/field_clinic_logo', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function uploadLogo(Request $request): JsonResponse
+353 -9
View File
@@ -12,12 +12,14 @@ use App\Doctor\Repository\DoctorRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use App\Shared\Service\FileValidatorService;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Doctors')]
class DoctorController extends BaseController
{
public function __construct(
@@ -31,6 +33,51 @@ class DoctorController extends BaseController
// ── Doctor CRUD ───────────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/doctor',
summary: 'Create a new doctor profile',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['title'],
properties: [
new OA\Property(property: 'title', type: 'string', description: 'Doctor name (also accepted as "name")'),
new OA\Property(property: 'gender', type: 'string', nullable: true),
new OA\Property(property: 'medical_system_code', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(
property: 'specialties',
type: 'array',
items: new OA\Items(type: 'integer'),
nullable: true,
),
new OA\Property(
property: 'doctor_services',
type: 'array',
items: new OA\Items(type: 'integer'),
nullable: true,
),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Doctor created successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Doctor detail object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 409, description: 'Doctor profile already exists'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[Route('/api/v1/doctor', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -61,6 +108,26 @@ class DoctorController extends BaseController
return $this->success(['data' => $doctor->toDetailArray()], 201);
}
#[OA\Get(
path: '/api/v1/doctor/{uuid}',
summary: 'Get a doctor by UUID',
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Doctor detail',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Doctor detail object'),
]
)
),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[Route('/api/v1/doctor/{uuid}', methods: ['GET'])]
public function show(string $uuid): JsonResponse
{
@@ -72,6 +139,39 @@ class DoctorController extends BaseController
return $this->success(['data' => $doctor->toDetailArray()]);
}
#[OA\Get(
path: '/api/v1/doctors',
summary: 'List doctors with optional filters (paginated)',
parameters: [
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20)),
new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'specialty_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'city_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'state_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: 'Paginated doctor list',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
new OA\Property(
property: 'meta',
properties: [
new OA\Property(property: 'totalRecords', type: 'integer'),
new OA\Property(property: 'totalPages', type: 'integer'),
new OA\Property(property: 'currentPage', type: 'integer'),
],
type: 'object'
),
]
)
),
]
)]
#[Route('/api/v1/doctors', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
@@ -86,6 +186,53 @@ class DoctorController extends BaseController
);
}
#[OA\Patch(
path: '/api/v1/doctor/{uuid}',
summary: 'Update a doctor profile',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'title', type: 'string', nullable: true),
new OA\Property(property: 'gender', type: 'string', nullable: true),
new OA\Property(property: 'medical_system_code', type: 'string', nullable: true),
new OA\Property(property: 'degree', type: 'string', nullable: true),
new OA\Property(property: 'info', type: 'string', nullable: true),
new OA\Property(
property: 'specialties',
type: 'array',
items: new OA\Items(type: 'integer'),
nullable: true,
),
new OA\Property(
property: 'doctor_services',
type: 'array',
items: new OA\Items(type: 'integer'),
nullable: true,
),
]
)
),
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Doctor updated successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Doctor detail object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
@@ -108,6 +255,31 @@ class DoctorController extends BaseController
return $this->success(['data' => $doctor->toDetailArray()]);
}
#[OA\Delete(
path: '/api/v1/doctor/{uuid}',
summary: 'Delete a doctor (ROLE_ADMIN only)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Doctor deleted successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', properties: [
new OA\Property(property: 'message', type: 'string'),
]),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[Route('/api/v1/doctor/{uuid}', methods: ['DELETE'])]
#[IsGranted('ROLE_ADMIN')]
public function delete(string $uuid): JsonResponse
@@ -123,6 +295,52 @@ class DoctorController extends BaseController
// ── File Upload ───────────────────────────────────────────────────────────
#[OA\Post(
path: '/file/upload/clinic_pro/doctor/field_image',
summary: 'Upload a doctor profile image (raw binary)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\MediaType(
mediaType: 'application/octet-stream',
schema: new OA\Schema(type: 'string', format: 'binary')
)
),
parameters: [
new OA\Parameter(
name: 'Content-Disposition',
in: 'header',
required: true,
description: 'Must include filename, e.g. attachment; filename="photo.jpg"',
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'File uploaded successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'fid', type: 'integer'),
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'url', type: 'string'),
new OA\Property(property: 'filename', type: 'string'),
new OA\Property(property: 'filemime', type: 'string'),
new OA\Property(property: 'filesize', type: 'integer'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 422, description: 'Invalid file'),
]
)]
#[Route('/file/upload/clinic_pro/doctor/field_image', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function uploadImage(Request $request): JsonResponse
@@ -171,6 +389,39 @@ class DoctorController extends BaseController
// ── Doctor Addresses ──────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/clinic-pro/doctor-address',
summary: 'Create a new doctor address',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'name', type: 'string', nullable: true),
new OA\Property(property: 'address', type: 'string', nullable: true),
new OA\Property(property: 'telephone', type: 'string', nullable: true),
new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true),
new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Address created successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Address object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Doctor not found'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[Route('/api/v1/clinic-pro/doctor-address', methods: ['POST'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function createAddress(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -201,6 +452,28 @@ class DoctorController extends BaseController
return $this->success(['data' => $address->toArray()], 201);
}
#[OA\Get(
path: '/api/v1/clinic-pro/doctor-address/{id}',
summary: 'Get a doctor address by ID',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: 'Address detail',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Address object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'Address not found'),
]
)]
#[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['GET'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function showAddress(int $id): JsonResponse
@@ -213,6 +486,41 @@ class DoctorController extends BaseController
return $this->success(['data' => $address->toArray()]);
}
#[OA\Patch(
path: '/api/v1/clinic-pro/doctor-address/{id}',
summary: 'Update a doctor address',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'name', type: 'string', nullable: true),
new OA\Property(property: 'address', type: 'string', nullable: true),
new OA\Property(property: 'telephone', type: 'string', nullable: true),
new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true),
new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true),
]
)
),
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: 'Address updated successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Address object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Address not found'),
]
)]
#[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['PATCH'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function updateAddress(int $id, Request $request, #[CurrentUser] User $user): JsonResponse
@@ -233,6 +541,31 @@ class DoctorController extends BaseController
return $this->success(['data' => $address->toArray()]);
}
#[OA\Delete(
path: '/api/v1/clinic-pro/doctor-address/{id}',
summary: 'Delete a doctor address',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: 'Address deleted successfully',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', properties: [
new OA\Property(property: 'message', type: 'string'),
]),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Address not found'),
]
)]
#[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['DELETE'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function deleteAddress(int $id, #[CurrentUser] User $user): JsonResponse
@@ -250,6 +583,26 @@ class DoctorController extends BaseController
return $this->success(['message' => 'آدرس با موفقیت حذف شد']);
}
#[OA\Get(
path: '/api/v1/clinic-pro/doctor-addresses/{doctorId}',
summary: 'List all addresses for a doctor',
parameters: [
new OA\Parameter(name: 'doctorId', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: 'Array of address objects',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
]
)
),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[Route('/api/v1/clinic-pro/doctor-addresses/{doctorId}', methods: ['GET'])]
public function listAddresses(int $doctorId): JsonResponse
{
@@ -263,15 +616,6 @@ class DoctorController extends BaseController
return $this->success(['data' => $addresses]);
}
// ── Clinic/Doctor list (stub — implemented fully in Task 06) ──────────────
#[Route('/api/v1/clinic/doctor-list/{clinicUuid}', methods: ['GET'])]
public function clinicDoctorList(string $clinicUuid): JsonResponse
{
// Full implementation in Task 06 (Clinic entity not yet created)
return $this->success(['data' => []]);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function hydrateDoctor(Doctor $doctor, array $data): void
@@ -12,6 +12,7 @@ use App\Payment\Repository\PaymentRepository;
use App\Payment\Service\CircuitBreakerService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -19,6 +20,7 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Payments')]
class PaymentController extends BaseController
{
// Shaparak payment network callback IP ranges
@@ -39,6 +41,99 @@ class PaymentController extends BaseController
// ── Appointment Payment ───────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/payment/appointment',
summary: 'Initiate an appointment payment',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['appointment_uuid', 'gateway'],
properties: [
new OA\Property(property: 'appointment_uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'gateway', type: 'string', enum: ['mellat', 'sep']),
new OA\Property(property: 'frontend_address', type: 'string', format: 'uri', nullable: true),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Payment initiated',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'redirect_url', type: 'string', format: 'uri'),
new OA\Property(property: 'order_id', type: 'string'),
],
type: 'object'
),
]
)
),
new OA\Response(
response: 401,
description: 'Unauthorized',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
new OA\Response(
response: 422,
description: 'Validation error',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
new OA\Response(
response: 503,
description: 'Gateway unavailable',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/payment/appointment', methods: ['POST'])]
public function initiateAppointment(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -100,6 +195,33 @@ class PaymentController extends BaseController
// ── Payment Callback (public — no JWT) ───────────────────────────────────
#[OA\Post(
path: '/api/v1/payment/callback/{gateway}',
summary: 'Payment gateway callback (public, IP-restricted)',
parameters: [
new OA\Parameter(
name: 'gateway',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string', enum: ['mellat', 'sep'])
),
],
responses: [
new OA\Response(
response: 200,
description: 'Callback processed — either a redirect or JSON result',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean'),
new OA\Property(property: 'payment', type: 'object'),
]
)
),
new OA\Response(response: 302, description: 'Redirect to frontend with payment result'),
new OA\Response(response: 403, description: 'Forbidden — IP not in allowed Shaparak ranges'),
new OA\Response(response: 404, description: 'Payment not found'),
]
)]
#[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])]
public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
{
@@ -139,6 +261,99 @@ class PaymentController extends BaseController
// ── Subscription Payment ──────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/subscription-payment',
summary: 'Initiate a subscription payment',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['gateway', 'amount_rials'],
properties: [
new OA\Property(property: 'gateway', type: 'string', enum: ['mellat', 'sep']),
new OA\Property(property: 'frontend_address', type: 'string', format: 'uri', nullable: true),
new OA\Property(property: 'amount_rials', type: 'integer', minimum: 1),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Subscription payment initiated',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'redirect_url', type: 'string', format: 'uri'),
new OA\Property(property: 'order_id', type: 'string'),
],
type: 'object'
),
]
)
),
new OA\Response(
response: 401,
description: 'Unauthorized',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
new OA\Response(
response: 422,
description: 'Validation error',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
new OA\Response(
response: 503,
description: 'Gateway unavailable',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(
property: 'errors',
type: 'array',
items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)
),
]
)
),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/subscription-payment', methods: ['POST'])]
public function initiateSubscription(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -187,6 +402,33 @@ class PaymentController extends BaseController
]);
}
#[OA\Post(
path: '/api/v1/subscription-payment/callback/{gateway}',
summary: 'Subscription payment gateway callback (public, IP-restricted)',
parameters: [
new OA\Parameter(
name: 'gateway',
in: 'path',
required: true,
schema: new OA\Schema(type: 'string', enum: ['mellat', 'sep'])
),
],
responses: [
new OA\Response(
response: 200,
description: 'Callback processed — either a redirect or JSON result',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean'),
new OA\Property(property: 'payment', type: 'object'),
]
)
),
new OA\Response(response: 302, description: 'Redirect to frontend with payment result'),
new OA\Response(response: 403, description: 'Forbidden — IP not in allowed Shaparak ranges'),
new OA\Response(response: 404, description: 'Payment not found'),
]
)]
#[Route('/api/v1/subscription-payment/callback/{gateway}', methods: ['POST', 'GET'])]
public function subscriptionCallback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response
{
@@ -195,6 +437,51 @@ class PaymentController extends BaseController
// ── Status ────────────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/payment/{uuid}',
summary: 'Get payment status by UUID',
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: 'Payment details',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'uuid', type: 'string', format: 'uuid'),
new OA\Property(property: 'status', type: 'string'),
new OA\Property(property: 'amount_rials', type: 'integer'),
new OA\Property(property: 'gateway', type: 'string'),
new OA\Property(property: 'reference_id', type: 'string', nullable: true),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
],
type: 'object'
),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Payment not found'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/payment/{uuid}', methods: ['GET'])]
public function getStatus(string $uuid, #[CurrentUser] User $user): JsonResponse
+244
View File
@@ -12,12 +12,14 @@ use App\Rating\Repository\LikeRepository;
use App\Rating\Repository\RateRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Ratings & Comments')]
class RatingController extends BaseController
{
public function __construct(
@@ -29,6 +31,36 @@ class RatingController extends BaseController
// ── Ratings ───────────────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/rate',
summary: 'Submit or update a rating for a doctor',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['doctor_uuid', 'score'],
properties: [
new OA\Property(property: 'doctor_uuid', type: 'string', description: 'UUID of the doctor'),
new OA\Property(property: 'score', type: 'integer', minimum: 1, maximum: 5, description: 'Rating score (15)'),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Rate object created',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Rate object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'Doctor not found'),
new OA\Response(response: 422, description: 'Validation error — score out of range'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/rate', methods: ['POST'])]
public function rate(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -59,6 +91,32 @@ class RatingController extends BaseController
return $this->success(['data' => $rate->toArray()], 201);
}
#[OA\Get(
path: '/api/v1/rate/{doctorUuid}',
summary: 'Get average rating for a doctor',
parameters: [
new OA\Parameter(name: 'doctorUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Average rating',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'average', type: 'number', format: 'float'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[Route('/api/v1/rate/{doctorUuid}', methods: ['GET'])]
public function getAverage(string $doctorUuid): JsonResponse
{
@@ -72,6 +130,36 @@ class RatingController extends BaseController
// ── Comments ──────────────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/comment',
summary: 'Submit a comment for a doctor',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['doctor_uuid', 'body'],
properties: [
new OA\Property(property: 'doctor_uuid', type: 'string', description: 'UUID of the doctor'),
new OA\Property(property: 'body', type: 'string', description: 'Comment text'),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Comment created',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Comment object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'Doctor not found'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/comment', methods: ['POST'])]
public function createComment(Request $request, #[CurrentUser] User $user): JsonResponse
@@ -95,6 +183,26 @@ class RatingController extends BaseController
return $this->success(['data' => $comment->toArray()], 201);
}
#[OA\Get(
path: '/api/v1/comments/{doctorUuid}',
summary: 'List approved comments for a doctor',
parameters: [
new OA\Parameter(name: 'doctorUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Array of comment objects',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
]
)
),
new OA\Response(response: 404, description: 'Doctor not found'),
]
)]
#[Route('/api/v1/comments/{doctorUuid}', methods: ['GET'])]
public function listComments(string $doctorUuid): JsonResponse
{
@@ -111,6 +219,31 @@ class RatingController extends BaseController
return $this->success(['data' => $comments]);
}
#[OA\Delete(
path: '/api/v1/comment/{uuid}',
summary: 'Delete a comment (owner or ROLE_ADMIN)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Comment deleted',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', properties: [
new OA\Property(property: 'message', type: 'string'),
]),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Comment not found'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/comment/{uuid}', methods: ['DELETE'])]
public function deleteComment(string $uuid, #[CurrentUser] User $user): JsonResponse
@@ -130,6 +263,25 @@ class RatingController extends BaseController
// ── Admin: comment moderation ─────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/comments/pending',
summary: 'List all pending comments (ROLE_ADMIN only)',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(
response: 200,
description: 'Array of pending comment objects',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN required'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/comments/pending', methods: ['GET'])]
public function pendingComments(): JsonResponse
@@ -138,6 +290,29 @@ class RatingController extends BaseController
return $this->success(['data' => $comments]);
}
#[OA\Post(
path: '/api/v1/admin/comment/{uuid}/approve',
summary: 'Approve a comment (ROLE_ADMIN only)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Comment approved',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Comment object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Comment not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/comment/{uuid}/approve', methods: ['POST'])]
public function approveComment(string $uuid): JsonResponse
@@ -151,6 +326,29 @@ class RatingController extends BaseController
return $this->success(['data' => $comment->toArray()]);
}
#[OA\Post(
path: '/api/v1/admin/comment/{uuid}/reject',
summary: 'Reject a comment (ROLE_ADMIN only)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Comment rejected',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object', description: 'Comment object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Comment not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/comment/{uuid}/reject', methods: ['POST'])]
public function rejectComment(string $uuid): JsonResponse
@@ -166,6 +364,52 @@ class RatingController extends BaseController
// ── Likes (toggle) ────────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/like/{commentUuid}',
summary: 'Toggle like on a comment',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'commentUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Like removed (unliked)',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'liked', type: 'boolean', example: false),
new OA\Property(property: 'likes', type: 'integer'),
],
type: 'object'
),
]
)
),
new OA\Response(
response: 201,
description: 'Like added',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(
property: 'data',
properties: [
new OA\Property(property: 'liked', type: 'boolean', example: true),
new OA\Property(property: 'likes', type: 'integer'),
],
type: 'object'
),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'Comment not found'),
]
)]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
#[Route('/api/v1/like/{commentUuid}', methods: ['POST'])]
public function toggleLike(string $commentUuid, #[CurrentUser] User $user): JsonResponse
@@ -10,12 +10,14 @@ use App\Representation\Service\JalaliDateService;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use Doctrine\ORM\EntityManagerInterface;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Representations')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class RepresentationController extends BaseController
{
@@ -28,6 +30,54 @@ class RepresentationController extends BaseController
// ── CRUD ──────────────────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/representation',
summary: 'Create a new representation (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['full_name', 'mobile_number'],
properties: [
new OA\Property(property: 'full_name', type: 'string'),
new OA\Property(property: 'mobile_number', type: 'string'),
new OA\Property(property: 'city_id', type: 'integer', nullable: true),
new OA\Property(property: 'commission_percent', type: 'number', format: 'float', nullable: true),
new OA\Property(property: 'bank_account', type: 'object', nullable: true),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Representation created',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(
response: 409,
description: 'Conflict user is already a representation',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: false),
new OA\Property(property: 'errors', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'code', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
]
)),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/representation', methods: ['POST'])]
public function create(Request $request): JsonResponse
@@ -62,6 +112,29 @@ class RepresentationController extends BaseController
return $this->success(['data' => $rep->toArray()], 201);
}
#[OA\Get(
path: '/api/v1/representation/{uuid}',
summary: 'Get a representation by UUID (own or admin)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Representation details',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
#[Route('/api/v1/representation/{uuid}', methods: ['GET'])]
public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
{
@@ -77,6 +150,41 @@ class RepresentationController extends BaseController
return $this->success(['data' => $rep->toArray()]);
}
#[OA\Patch(
path: '/api/v1/representation/{uuid}',
summary: 'Update a representation (own or admin)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'full_name', type: 'string'),
new OA\Property(property: 'city_id', type: 'integer', nullable: true),
new OA\Property(property: 'bank_account', type: 'object', nullable: true),
new OA\Property(property: 'commission_percent', type: 'number', format: 'float'),
new OA\Property(property: 'active', type: 'boolean'),
]
)
),
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Representation updated',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
#[Route('/api/v1/representation/{uuid}', methods: ['PATCH'])]
public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
@@ -101,6 +209,31 @@ class RepresentationController extends BaseController
return $this->success(['data' => $rep->toArray()]);
}
#[OA\Delete(
path: '/api/v1/representation/{uuid}',
summary: 'Delete a representation (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Representation deleted',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'message', type: 'string'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/representation/{uuid}', methods: ['DELETE'])]
public function delete(string $uuid): JsonResponse
@@ -116,6 +249,41 @@ class RepresentationController extends BaseController
// ── Dashboard: monthly stats ──────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/representation/{uuid}/dashboard/monthly',
summary: 'Get monthly dashboard stats for a representation',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'year', in: 'query', required: false, description: 'Jalali year', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'month', in: 'query', required: false, description: 'Jalali month (112)', schema: new OA\Schema(type: 'integer', minimum: 1, maximum: 12)),
],
responses: [
new OA\Response(
response: 200,
description: 'Monthly statistics',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'period', properties: [
new OA\Property(property: 'jalali_year', type: 'integer'),
new OA\Property(property: 'jalali_month', type: 'integer'),
], type: 'object'),
new OA\Property(property: 'stats', properties: [
new OA\Property(property: 'total_payments', type: 'integer'),
new OA\Property(property: 'total_revenue_rials', type: 'integer'),
new OA\Property(property: 'total_appointments', type: 'integer'),
], type: 'object'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
#[Route('/api/v1/representation/{uuid}/dashboard/monthly', methods: ['GET'])]
public function dashboardMonthly(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
@@ -139,6 +307,49 @@ class RepresentationController extends BaseController
]);
}
#[OA\Get(
path: '/api/v1/representation/{uuid}/dashboard/yearly',
summary: 'Get yearly dashboard stats for a representation',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'year', in: 'query', required: false, description: 'Jalali year', schema: new OA\Schema(type: 'integer')),
],
responses: [
new OA\Response(
response: 200,
description: 'Yearly statistics broken down by month',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'period', properties: [
new OA\Property(property: 'jalali_year', type: 'integer'),
], type: 'object'),
new OA\Property(property: 'months', type: 'array', items: new OA\Items(
properties: [
new OA\Property(property: 'jalali_month', type: 'integer'),
new OA\Property(property: 'stats', properties: [
new OA\Property(property: 'total_payments', type: 'integer'),
new OA\Property(property: 'total_revenue_rials', type: 'integer'),
new OA\Property(property: 'total_appointments', type: 'integer'),
], type: 'object'),
]
)),
new OA\Property(property: 'totals', properties: [
new OA\Property(property: 'total_payments', type: 'integer'),
new OA\Property(property: 'total_revenue_rials', type: 'integer'),
new OA\Property(property: 'total_appointments', type: 'integer'),
], type: 'object'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
#[Route('/api/v1/representation/{uuid}/dashboard/yearly', methods: ['GET'])]
public function dashboardYearly(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
@@ -9,12 +9,14 @@ use App\Settlement\Repository\SettlementRepository;
use App\Settlement\Repository\WalletTransactionRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Controller\BaseController;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'Settlements')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class SettlementController extends BaseController
{
@@ -25,6 +27,27 @@ class SettlementController extends BaseController
// ── Wallet ────────────────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/wallet/balance',
summary: 'Get current wallet balance and recent transactions',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(
response: 200,
description: 'Wallet balance and recent transactions',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'balance_rials', type: 'integer'),
new OA\Property(property: 'recent_transactions', type: 'array', items: new OA\Items(type: 'object')),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
]
)]
#[Route('/api/v1/wallet/balance', methods: ['GET'])]
public function balance(#[CurrentUser] User $user): JsonResponse
{
@@ -40,6 +63,24 @@ class SettlementController extends BaseController
]);
}
#[OA\Get(
path: '/api/v1/wallet/transactions',
summary: 'List all wallet transactions for the authenticated user',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(
response: 200,
description: 'List of wallet transactions',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
]
)]
#[Route('/api/v1/wallet/transactions', methods: ['GET'])]
public function transactions(#[CurrentUser] User $user): JsonResponse
{
@@ -53,6 +94,35 @@ class SettlementController extends BaseController
// ── Settlement Requests ───────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/settlement',
summary: 'Request a new settlement (withdrawal)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['amount_rials'],
properties: [
new OA\Property(property: 'amount_rials', type: 'integer', minimum: 1),
new OA\Property(property: 'bank_account', type: 'object', nullable: true),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Settlement request created',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 422, description: 'Validation error or insufficient balance'),
]
)]
#[Route('/api/v1/settlement', methods: ['POST'])]
public function request(Request $request, #[CurrentUser] User $user): JsonResponse
{
@@ -80,6 +150,24 @@ class SettlementController extends BaseController
return $this->success(['data' => $settlement->toArray()], 201);
}
#[OA\Get(
path: '/api/v1/settlement',
summary: 'List settlement requests for the authenticated user',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(
response: 200,
description: 'List of settlement requests',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
]
)]
#[Route('/api/v1/settlement', methods: ['GET'])]
public function listMine(#[CurrentUser] User $user): JsonResponse
{
@@ -91,6 +179,29 @@ class SettlementController extends BaseController
return $this->success(['data' => $settlements]);
}
#[OA\Get(
path: '/api/v1/settlement/{uuid}',
summary: 'Get a settlement request by UUID (own or admin)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Settlement request details',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
#[Route('/api/v1/settlement/{uuid}', methods: ['GET'])]
public function get(string $uuid, #[CurrentUser] User $user): JsonResponse
{
@@ -108,6 +219,38 @@ class SettlementController extends BaseController
// ── Admin Actions ─────────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/settlement/{uuid}/approve',
summary: 'Approve a pending settlement request (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'note', type: 'string', nullable: true),
]
)
),
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Settlement approved',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Not found'),
new OA\Response(response: 422, description: 'Settlement is not in pending state'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/settlement/{uuid}/approve', methods: ['POST'])]
public function approve(string $uuid, Request $request, #[CurrentUser] User $admin): JsonResponse
@@ -128,6 +271,39 @@ class SettlementController extends BaseController
return $this->success(['data' => $settlement->toArray()]);
}
#[OA\Post(
path: '/api/v1/settlement/{uuid}/reject',
summary: 'Reject a pending settlement request (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['note'],
properties: [
new OA\Property(property: 'note', type: 'string', description: 'Rejection reason (required)'),
]
)
),
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Settlement rejected and amount refunded to wallet',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Not found'),
new OA\Response(response: 422, description: 'Settlement is not pending or note is missing'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/settlement/{uuid}/reject', methods: ['POST'])]
public function reject(string $uuid, Request $request, #[CurrentUser] User $admin): JsonResponse
@@ -25,6 +25,10 @@ class ExceptionSubscriber implements EventSubscriberInterface
public function onKernelException(ExceptionEvent $event): void
{
if (str_starts_with($event->getRequest()->getPathInfo(), '/api/doc')) {
return;
}
$exception = $event->getThrowable();
if ($exception instanceof AppException) {
@@ -25,7 +25,8 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) {
$path = $event->getRequest()->getPathInfo();
if (str_starts_with($path, '/api') && !str_starts_with($path, '/api/doc')) {
$response->headers->set('Content-Security-Policy', "default-src 'none'");
}
}
+289
View File
@@ -8,12 +8,14 @@ use App\Shared\Controller\BaseController;
use App\Sms\Entity\SmsTemplate;
use App\Sms\Repository\SmsTemplateRepository;
use App\Sms\Service\SmsService;
use OpenApi\Attributes as OA;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[OA\Tag(name: 'SMS')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class SmsController extends BaseController
{
@@ -24,6 +26,39 @@ class SmsController extends BaseController
// ── Send SMS directly ─────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/sms/send',
summary: 'Send an SMS message directly (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['mobile', 'message'],
properties: [
new OA\Property(property: 'mobile', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
new OA\Property(property: 'provider', type: 'string', default: 'kavenegar'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'SMS queued for delivery',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'message', type: 'string'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/sms/send', methods: ['POST'])]
public function send(Request $request): JsonResponse
@@ -44,6 +79,37 @@ class SmsController extends BaseController
// ── Templates ─────────────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/sms/template',
summary: 'Create a new SMS template (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['name', 'body'],
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'body', type: 'string'),
new OA\Property(property: 'variables', type: 'array', items: new OA\Items(type: 'string')),
]
)
),
responses: [
new OA\Response(
response: 201,
description: 'Template created',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 422, description: 'Validation error'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/sms/template', methods: ['POST'])]
public function createTemplate(Request $request): JsonResponse
@@ -63,6 +129,28 @@ class SmsController extends BaseController
return $this->success(['data' => $template->toArray()], 201);
}
#[OA\Get(
path: '/api/v1/sms/template/{uuid}',
summary: 'Get an SMS template by UUID',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Template details',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
#[Route('/api/v1/sms/template/{uuid}', methods: ['GET'])]
public function getTemplate(string $uuid): JsonResponse
{
@@ -73,6 +161,40 @@ class SmsController extends BaseController
return $this->success(['data' => $template->toArray()]);
}
#[OA\Patch(
path: '/api/v1/sms/template/{uuid}',
summary: 'Update an SMS template (ROLE_ADMIN, only non-approved templates)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'body', type: 'string'),
new OA\Property(property: 'variables', type: 'array', items: new OA\Items(type: 'string')),
]
)
),
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Template updated',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Not found'),
new OA\Response(response: 422, description: 'Template is already approved and cannot be edited'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/sms/template/{uuid}', methods: ['PATCH'])]
public function updateTemplate(string $uuid, Request $request): JsonResponse
@@ -96,6 +218,29 @@ class SmsController extends BaseController
return $this->success(['data' => $template->toArray()]);
}
#[OA\Post(
path: '/api/v1/sms/template/{uuid}/submit',
summary: 'Submit an SMS template for review (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Template submitted for review',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/sms/template/{uuid}/submit', methods: ['POST'])]
public function submitTemplate(string $uuid): JsonResponse
@@ -111,6 +256,31 @@ class SmsController extends BaseController
return $this->success(['data' => $template->toArray()]);
}
#[OA\Delete(
path: '/api/v1/sms/template/{uuid}',
summary: 'Delete an SMS template (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Template deleted',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'message', type: 'string'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/sms/template/{uuid}', methods: ['DELETE'])]
public function deleteTemplate(string $uuid): JsonResponse
@@ -125,6 +295,25 @@ class SmsController extends BaseController
// ── Admin moderation ──────────────────────────────────────────────────────
#[OA\Get(
path: '/api/v1/admin/sms/templates',
summary: 'List all SMS templates (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
responses: [
new OA\Response(
response: 200,
description: 'List of all SMS templates',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/sms/templates', methods: ['GET'])]
public function listTemplates(): JsonResponse
@@ -133,6 +322,38 @@ class SmsController extends BaseController
return $this->success(['data' => $templates]);
}
#[OA\Post(
path: '/api/v1/admin/sms/template/{uuid}/approve',
summary: 'Approve an SMS template (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: false,
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'note', type: 'string', nullable: true),
new OA\Property(property: 'provider_code', type: 'string', nullable: true),
]
)
),
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Template approved',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Not found'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/sms/template/{uuid}/approve', methods: ['POST'])]
public function approveTemplate(string $uuid, Request $request): JsonResponse
@@ -150,6 +371,39 @@ class SmsController extends BaseController
return $this->success(['data' => $template->toArray()]);
}
#[OA\Post(
path: '/api/v1/admin/sms/template/{uuid}/reject',
summary: 'Reject an SMS template (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['note'],
properties: [
new OA\Property(property: 'note', type: 'string', description: 'Rejection reason (required)'),
]
)
),
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Template rejected',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Not found'),
new OA\Response(response: 422, description: 'Rejection note is required'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/admin/sms/template/{uuid}/reject', methods: ['POST'])]
public function rejectTemplate(string $uuid, Request $request): JsonResponse
@@ -172,6 +426,41 @@ class SmsController extends BaseController
// ── Send via template ─────────────────────────────────────────────────────
#[OA\Post(
path: '/api/v1/sms/send-template',
summary: 'Send an SMS using an approved template (ROLE_ADMIN)',
security: [['bearerAuth' => []]],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['mobile', 'template_uuid'],
properties: [
new OA\Property(property: 'mobile', type: 'string'),
new OA\Property(property: 'template_uuid', type: 'string'),
new OA\Property(property: 'vars', type: 'object', description: 'Key-value map of template variables'),
new OA\Property(property: 'provider', type: 'string', default: 'kavenegar'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'SMS queued via template',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'success', type: 'boolean', example: true),
new OA\Property(property: 'data', properties: [
new OA\Property(property: 'message', type: 'string'),
], type: 'object'),
]
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 403, description: 'Forbidden ROLE_ADMIN required'),
new OA\Response(response: 404, description: 'Template not found'),
new OA\Response(response: 422, description: 'Template not yet approved'),
]
)]
#[IsGranted('ROLE_ADMIN')]
#[Route('/api/v1/sms/send-template', methods: ['POST'])]
public function sendViaTemplate(Request $request): JsonResponse