From a8d36d745574dc9e35ef6b23611fcd04813026ba Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 19 Jun 2026 10:19:47 +0330 Subject: [PATCH] feat(user-profile): add avatar upload functionality and update UserProfile entity --- config/services.yaml | 4 ++ docs/api/appointment.md | 1 + docs/api/user-profile.md | 24 ++++++++ migrations/Version20260619062912.php | 31 ++++++++++ .../Controller/MyAppointmentsController.php | 4 +- .../Controller/UserProfileController.php | 58 +++++++++++++++++++ src/UserProfile/Entity/UserProfile.php | 6 ++ 7 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20260619062912.php diff --git a/config/services.yaml b/config/services.yaml index 2e16247c..af036689 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -27,6 +27,10 @@ services: arguments: $projectDir: '%kernel.project_dir%' + App\UserProfile\Controller\UserProfileController: + arguments: + $projectDir: '%kernel.project_dir%' + App\Auth\Service\OtpService: arguments: $otpTtl: '%env(int:OTP_TTL)%' diff --git a/docs/api/appointment.md b/docs/api/appointment.md index 11947a30..d3428e74 100644 --- a/docs/api/appointment.md +++ b/docs/api/appointment.md @@ -430,6 +430,7 @@ Role-aware paginated list of appointments. Returns only what the authenticated u | `ROLE_CLINIC` | Appointments for doctors in this clinic | | `ROLE_DOCTOR` | Appointments for this doctor | | `ROLE_SECRETARY` | Appointments for the linked doctor (empty if `appointments.view` permission is false) | +| (plain patient `ROLE_USER`) | The patient's own appointments (`a.user = current user`) | ### Query Parameters | Param | Type | Default | Description | diff --git a/docs/api/user-profile.md b/docs/api/user-profile.md index 18ca4e24..182a49dc 100644 --- a/docs/api/user-profile.md +++ b/docs/api/user-profile.md @@ -154,3 +154,27 @@ Delete a user profile. | `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_006` | 403 | Not admin | | `ERR_NOT_FOUND_001` | 404 | Profile not found | + +--- + +## POST `/api/v1/user-profile/avatar` + +Upload the current user's profile avatar. Creates the profile if it doesn't exist yet. + +**Permission:** `IS_AUTHENTICATED_FULLY` + +### Request +Raw file body with `Content-Disposition: filename="..."` and `Content-Type: application/octet-stream`. + +### Response `200` +```json +{ "success": true, "data": { "avatar": "/uploads/avatars/2026-06/xxx.jpg", "url": "/uploads/avatars/2026-06/xxx.jpg" } } +``` + +The stored path is also returned as `avatar` in the profile GET/PATCH responses. + +### Errors +| Code | HTTP | Description | +|------|------|-------------| +| `ERR_VALIDATION_001` | 422 | Missing or invalid file | +| `ERR_AUTH_001` | 401 | Missing token | diff --git a/migrations/Version20260619062912.php b/migrations/Version20260619062912.php new file mode 100644 index 00000000..0c0bbb97 --- /dev/null +++ b/migrations/Version20260619062912.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE profiles ADD avatar VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE profiles DROP avatar'); + } +} diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 6b53a161..e0e6b47a 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -149,7 +149,9 @@ class MyAppointmentsController extends BaseController ->setParameter('doctor', $filterValue); } } else { - return $this->paginated([], 0, $page, $limit); + // Plain patient: only their own appointments. + $qb->andWhere('a.user = :patient') + ->setParameter('patient', $user); } if ($search !== '') { diff --git a/src/UserProfile/Controller/UserProfileController.php b/src/UserProfile/Controller/UserProfileController.php index 9c07b9a4..af9d2c97 100644 --- a/src/UserProfile/Controller/UserProfileController.php +++ b/src/UserProfile/Controller/UserProfileController.php @@ -6,8 +6,10 @@ use App\Auth\Entity\User; use App\Auth\Repository\UserRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; +use App\Shared\Service\FileValidatorService; use App\UserProfile\Entity\UserProfile; use App\UserProfile\Repository\UserProfileRepository; +use Symfony\Component\Uid\Uuid; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; @@ -22,8 +24,64 @@ class UserProfileController extends BaseController public function __construct( private readonly UserProfileRepository $repository, private readonly UserRepository $userRepository, + private readonly FileValidatorService $fileValidator, + private readonly string $projectDir, ) {} + #[OA\Post( + path: '/api/v1/user-profile/avatar', + summary: 'Upload the current user\'s profile avatar', + 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: 'Avatar uploaded'), + new OA\Response(response: 422, description: 'Invalid file'), + ] + )] + #[Route('/api/v1/user-profile/avatar', methods: ['POST'])] + public function uploadAvatar(Request $request, #[CurrentUser] User $user): JsonResponse + { + $profile = $this->repository->findByUser($user) ?? new UserProfile($user); + + $content = $request->getContent(); + if ($content === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فایلی ارسال نشده است', 422); + } + $disposition = $request->headers->get('Content-Disposition', ''); + preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m); + $filename = $m[1] ?? 'avatar.jpg'; + + $tmpPath = sys_get_temp_dir() . '/' . uniqid('avatar_', true); + file_put_contents($tmpPath, $content); + + try { + $safeFilename = $this->fileValidator->sanitizeFilename($filename); + $this->fileValidator->detectMimeType($tmpPath); + + $year = date('Y'); $month = date('m'); + $dir = $this->projectDir . '/public/uploads/avatars/' . $year . '-' . $month; + if (!is_dir($dir)) mkdir($dir, 0755, true); + + $storedName = uniqid('', true) . '_' . $safeFilename; + rename($tmpPath, $dir . '/' . $storedName); + + $url = '/uploads/avatars/' . $year . '-' . $month . '/' . $storedName; + $profile->setAvatar($url); + $this->repository->save($profile); + + return $this->success(['avatar' => $url, 'url' => $url]); + } catch (\Throwable $e) { + if (file_exists($tmpPath)) unlink($tmpPath); + return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422); + } + } + #[Route('/api/v1/user-profile', methods: ['POST'])] public function create(Request $request, #[CurrentUser] User $user): JsonResponse { diff --git a/src/UserProfile/Entity/UserProfile.php b/src/UserProfile/Entity/UserProfile.php index 97d7aba4..90091b65 100644 --- a/src/UserProfile/Entity/UserProfile.php +++ b/src/UserProfile/Entity/UserProfile.php @@ -84,6 +84,9 @@ class UserProfile #[ORM\Column(type: 'text', nullable: true)] private ?string $description = null; + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $avatar = null; + #[ORM\Column(name: 'created_at', type: 'integer')] private int $createdAt; @@ -122,6 +125,7 @@ class UserProfile public function getOther(): ?array { return $this->other; } public function isSharingWithUser(): bool { return $this->sharingWithUser; } public function getDescription(): ?string { return $this->description; } + public function getAvatar(): ?string { return $this->avatar; } public function getCreatedAt(): int { return $this->createdAt; } public function getUpdatedAt(): int { return $this->updatedAt; } @@ -145,6 +149,7 @@ class UserProfile public function setOther(?array $v): self { $this->other = $v; $this->touch(); return $this; } public function setSharingWithUser(bool $v): self { $this->sharingWithUser = $v; $this->touch(); return $this; } public function setDescription(?string $v): self { $this->description = $v; $this->touch(); return $this; } + public function setAvatar(?string $v): self { $this->avatar = $v; $this->touch(); return $this; } private function touch(): void { $this->updatedAt = time(); } @@ -174,6 +179,7 @@ class UserProfile 'other' => $this->other, 'sharing_with_user' => $this->sharingWithUser, 'description' => $this->description, + 'avatar' => $this->avatar, 'created_at' => $this->createdAt, 'updated_at' => $this->updatedAt, ];