feat(user-profile): add avatar upload functionality and update UserProfile entity
This commit is contained in:
@@ -27,6 +27,10 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$projectDir: '%kernel.project_dir%'
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
|
||||||
|
App\UserProfile\Controller\UserProfileController:
|
||||||
|
arguments:
|
||||||
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
|
||||||
App\Auth\Service\OtpService:
|
App\Auth\Service\OtpService:
|
||||||
arguments:
|
arguments:
|
||||||
$otpTtl: '%env(int:OTP_TTL)%'
|
$otpTtl: '%env(int:OTP_TTL)%'
|
||||||
|
|||||||
@@ -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_CLINIC` | Appointments for doctors in this clinic |
|
||||||
| `ROLE_DOCTOR` | Appointments for this doctor |
|
| `ROLE_DOCTOR` | Appointments for this doctor |
|
||||||
| `ROLE_SECRETARY` | Appointments for the linked doctor (empty if `appointments.view` permission is false) |
|
| `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
|
### Query Parameters
|
||||||
| Param | Type | Default | Description |
|
| Param | Type | Default | Description |
|
||||||
|
|||||||
@@ -154,3 +154,27 @@ Delete a user profile.
|
|||||||
| `ERR_AUTH_001` | 401 | Missing token |
|
| `ERR_AUTH_001` | 401 | Missing token |
|
||||||
| `ERR_AUTH_006` | 403 | Not admin |
|
| `ERR_AUTH_006` | 403 | Not admin |
|
||||||
| `ERR_NOT_FOUND_001` | 404 | Profile not found |
|
| `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 |
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260619062912 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,7 +149,9 @@ class MyAppointmentsController extends BaseController
|
|||||||
->setParameter('doctor', $filterValue);
|
->setParameter('doctor', $filterValue);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return $this->paginated([], 0, $page, $limit);
|
// Plain patient: only their own appointments.
|
||||||
|
$qb->andWhere('a.user = :patient')
|
||||||
|
->setParameter('patient', $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($search !== '') {
|
if ($search !== '') {
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ use App\Auth\Entity\User;
|
|||||||
use App\Auth\Repository\UserRepository;
|
use App\Auth\Repository\UserRepository;
|
||||||
use App\Shared\Constant\ErrorCodes;
|
use App\Shared\Constant\ErrorCodes;
|
||||||
use App\Shared\Controller\BaseController;
|
use App\Shared\Controller\BaseController;
|
||||||
|
use App\Shared\Service\FileValidatorService;
|
||||||
use App\UserProfile\Entity\UserProfile;
|
use App\UserProfile\Entity\UserProfile;
|
||||||
use App\UserProfile\Repository\UserProfileRepository;
|
use App\UserProfile\Repository\UserProfileRepository;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -22,8 +24,64 @@ class UserProfileController extends BaseController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserProfileRepository $repository,
|
private readonly UserProfileRepository $repository,
|
||||||
private readonly UserRepository $userRepository,
|
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'])]
|
#[Route('/api/v1/user-profile', methods: ['POST'])]
|
||||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ class UserProfile
|
|||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
private ?string $description = null;
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $avatar = null;
|
||||||
|
|
||||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||||
private int $createdAt;
|
private int $createdAt;
|
||||||
|
|
||||||
@@ -122,6 +125,7 @@ class UserProfile
|
|||||||
public function getOther(): ?array { return $this->other; }
|
public function getOther(): ?array { return $this->other; }
|
||||||
public function isSharingWithUser(): bool { return $this->sharingWithUser; }
|
public function isSharingWithUser(): bool { return $this->sharingWithUser; }
|
||||||
public function getDescription(): ?string { return $this->description; }
|
public function getDescription(): ?string { return $this->description; }
|
||||||
|
public function getAvatar(): ?string { return $this->avatar; }
|
||||||
public function getCreatedAt(): int { return $this->createdAt; }
|
public function getCreatedAt(): int { return $this->createdAt; }
|
||||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
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 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 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 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(); }
|
private function touch(): void { $this->updatedAt = time(); }
|
||||||
|
|
||||||
@@ -174,6 +179,7 @@ class UserProfile
|
|||||||
'other' => $this->other,
|
'other' => $this->other,
|
||||||
'sharing_with_user' => $this->sharingWithUser,
|
'sharing_with_user' => $this->sharingWithUser,
|
||||||
'description' => $this->description,
|
'description' => $this->description,
|
||||||
|
'avatar' => $this->avatar,
|
||||||
'created_at' => $this->createdAt,
|
'created_at' => $this->createdAt,
|
||||||
'updated_at' => $this->updatedAt,
|
'updated_at' => $this->updatedAt,
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user