From 537bb8c7b35e88c98ebeb7c8748a52bc38fa9484 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 13 Jul 2026 15:20:00 +0330 Subject: [PATCH] =?UTF-8?q?feat(patients):=20phase=20B2=20=E2=80=94=20atta?= =?UTF-8?q?chments=20(=D8=B6=D9=85=DB=8C=D9=85=D9=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add patient file attachments: a new PatientAttachment entity (record-scoped, CASCADE) + repository, and endpoints GET /patient/{uuid}/attachments, POST /patient/{uuid}/attachment (raw-body upload) and DELETE /patient/attachment/{uuid} (owner-scoped). Factor the shared raw-body upload logic into FileUploadService. Wire the "ضمیمه" tab in PatientDetailPage (upload + list + delete). PHPUnit covers list/delete/ownership; Vitest covers the tab. API docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- assets/admin/pages/PatientDetailPage.test.tsx | 8 ++ assets/admin/pages/PatientDetailPage.tsx | 91 ++++++++++++++++++- config/services.yaml | 4 + docs/api/patient.md | 23 +++++ migrations/Version20260713114523.php | 33 +++++++ src/Patient/Controller/PatientController.php | 55 +++++++++++ src/Patient/Entity/PatientAttachment.php | 69 ++++++++++++++ .../PatientAttachmentRepository.php | 44 +++++++++ src/Shared/Service/FileUploadService.php | 59 ++++++++++++ tests/Patient/PatientAttachmentTest.php | 62 +++++++++++++ 10 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 migrations/Version20260713114523.php create mode 100644 src/Patient/Entity/PatientAttachment.php create mode 100644 src/Patient/Repository/PatientAttachmentRepository.php create mode 100644 src/Shared/Service/FileUploadService.php create mode 100644 tests/Patient/PatientAttachmentTest.php diff --git a/assets/admin/pages/PatientDetailPage.test.tsx b/assets/admin/pages/PatientDetailPage.test.tsx index 1c8243e7..647890a5 100644 --- a/assets/admin/pages/PatientDetailPage.test.tsx +++ b/assets/admin/pages/PatientDetailPage.test.tsx @@ -58,4 +58,12 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => { fireEvent.click(screen.getByText('پیام‌ها')); expect(screen.getByText(/به‌زودی تکمیل می‌شود/)).toBeInTheDocument(); }); + + it('renders the attachments tab with an upload button', async () => { + renderDetail(); + await screen.findByText('ساغر صابری'); + fireEvent.click(screen.getByText('ضمیمه')); + expect(await screen.findByRole('button', { name: /آپلود فایل جدید/ })).toBeInTheDocument(); + expect(await screen.findByText('هنوز فایلی ضمیمه نشده است.')).toBeInTheDocument(); + }); }); diff --git a/assets/admin/pages/PatientDetailPage.tsx b/assets/admin/pages/PatientDetailPage.tsx index 9e37abc6..eb9eaa53 100644 --- a/assets/admin/pages/PatientDetailPage.tsx +++ b/assets/admin/pages/PatientDetailPage.tsx @@ -1,14 +1,17 @@ -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, Link } from 'react-router-dom'; import { ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon, PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon, + ArrowUpTrayIcon, TrashIcon, DocumentIcon, } from '@heroicons/react/24/outline'; +import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import type { PatientRecord } from '../types'; +import { useAuthStore } from '../stores/authStore'; import { formatDate } from '../lib/utils'; type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records'; @@ -120,6 +123,8 @@ export default function PatientDetailPage() { ) : tab === 'appointments' ? ( ({ title: a.service_name || a.doctor_name || 'نوبت', meta: a.date ? formatDate(a.date) : (a.starts_at ? formatDate(a.starts_at) : ''), badge: a.status_label || a.status })} /> + ) : tab === 'attach' ? ( + ) : ( t.key === tab)!.label} /> )} @@ -127,6 +132,88 @@ export default function PatientDetailPage() { ); } +interface Attachment { uuid: string; name: string; url: string; mime?: string | null; size?: number | null } + +const formatBytes = (n?: number | null) => { + if (!n) return ''; + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`; + return `${(n / 1024 / 1024).toFixed(1)} MB`; +}; + +/** ضمیمه — patient attachments: upload (raw body), list, delete. */ +function AttachmentsTab({ uuid }: { uuid: string }) { + const qc = useQueryClient(); + const fileRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const { data, isLoading } = useQuery>({ + queryKey: ['patient-attachments', uuid], + queryFn: () => api.get(`/api/v1/patient/${uuid}/attachments`), + }); + const items = data?.data ?? []; + + const del = useMutation({ + mutationFn: (attUuid: string) => api.delete(`/api/v1/patient/attachment/${attUuid}`), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['patient-attachments', uuid] }); toast.success('ضمیمه حذف شد'); }, + onError: (e: any) => toast.error(e.message), + }); + + const onFile = async (file: File) => { + setUploading(true); + try { + const token = useAuthStore.getState().token; + const res = await fetch(`/api/v1/patient/${uuid}/attachment?name=${encodeURIComponent(file.name)}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${file.name}"`, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: file, + }); + if (!res.ok) throw new Error('خطا در آپلود فایل'); + qc.invalidateQueries({ queryKey: ['patient-attachments', uuid] }); + toast.success('فایل آپلود شد'); + } catch (e: any) { + toast.error(e.message); + } finally { + setUploading(false); + if (fileRef.current) fileRef.current.value = ''; + } + }; + + return ( +
+
+ e.target.files?.[0] && onFile(e.target.files[0])} /> + +
+ + {isLoading ? ( +
در حال بارگذاری...
+ ) : items.length === 0 ? ( +
هنوز فایلی ضمیمه نشده است.
+ ) : ( +
+ {items.map((a) => ( +
+ +
+ {a.name} + {a.size ?
{formatBytes(a.size)}
: null} +
+ +
+ ))} +
+ )} +
+ ); +} + function TabList({ q, emptyLabel, row }: { q: { data?: ApiResponse; isLoading: boolean }; emptyLabel: string; diff --git a/config/services.yaml b/config/services.yaml index 8a1b3ed4..0d29c37b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -28,6 +28,10 @@ services: arguments: $environment: '%kernel.environment%' + App\Shared\Service\FileUploadService: + arguments: + $projectDir: '%kernel.project_dir%' + App\Doctor\Controller\DoctorController: arguments: $projectDir: '%kernel.project_dir%' diff --git a/docs/api/patient.md b/docs/api/patient.md index b603f88e..006246fd 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -483,3 +483,26 @@ When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointme **انتساب پزشک:** هر `PatientSession` در پاسخ، `doctor_uuid` و `doctor_name` را از روی نوبتِ متناظر برمی‌گرداند؛ پس در پرونده‌ی کلینیک مشخص است هر مراجعه برای کدام پزشک بوده است. **آدرس نوبت:** هنگام رزرو، `address_id` خودکار از `location_id` همان session برنامه‌ی هفتگی ست می‌شود (در همه‌ی مسیرهای رزرو). ثبت `location_id` برای هر شیفت فعال در برنامه‌ی هفتگی الزامی است (`POST/PATCH /api/v1/appointment-settings/weekly-schedule`)؛ در غیر این صورت `422`. + +--- + +## ضمیمه‌های بیمار (Attachments) + +فایل‌های پیوستِ یک پرونده. همه scope به رکورد و tenant صاحب رکورد. + +**Permission:** `IS_AUTHENTICATED_FULLY` (doctor/clinic/secretary مالک رکورد) + +### GET `/api/v1/patient/{uuid}/attachments` +لیست ضمیمه‌ها. Response: `{ success, data: [{ uuid, name, url, mime, size, created_at }] }` + +### POST `/api/v1/patient/{uuid}/attachment` +آپلود فایل به‌صورت **raw body** (مثل سایر `/file/upload/...`): بدنه = بایت‌های فایل، هدر `Content-Disposition: attachment; filename="..."`. نام نمایشی اختیاری از query `?name=`. فایل زیر `public/uploads/patients/attachments/YYYY-MM/` ذخیره می‌شود. Response `201`: attachment object. + +### DELETE `/api/v1/patient/attachment/{uuid}` +حذف ضمیمه. فقط مالک رکورد؛ در غیر این صورت `404`. + +### Errors +| HTTP | Code | Description | +|------|------|-------------| +| 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/ضمیمه یافت نشد یا متعلق به tenant دیگر | +| 422 | `ERR_VALIDATION_001` | فایل نامعتبر | diff --git a/migrations/Version20260713114523.php b/migrations/Version20260713114523.php new file mode 100644 index 00000000..9dedda3d --- /dev/null +++ b/migrations/Version20260713114523.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE patient_attachments (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(200) NOT NULL, url VARCHAR(500) NOT NULL, mime VARCHAR(100) DEFAULT NULL, size INT DEFAULT NULL, created_at INT NOT NULL, record_id INT NOT NULL, UNIQUE INDEX UNIQ_77C7C599D17F50A6 (uuid), INDEX idx_patient_attachments_record (record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE patient_attachments ADD CONSTRAINT FK_77C7C5994DFD750C FOREIGN KEY (record_id) REFERENCES patient_records (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE patient_attachments DROP FOREIGN KEY FK_77C7C5994DFD750C'); + $this->addSql('DROP TABLE patient_attachments'); + } +} diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index 4885a38e..5b9b95a2 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -50,9 +50,64 @@ class PatientController extends BaseController private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo, private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo, private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo, + private readonly \App\Patient\Repository\PatientAttachmentRepository $attachmentRepo, + private readonly \App\Shared\Service\FileUploadService $fileUpload, private readonly LoggerInterface $logger, ) {} + // ── Attachments (ضمیمه) ─────────────────────────────────────────────────── + + #[Route('/api/v1/patient/{uuid}/attachments', methods: ['GET'])] + public function listAttachments(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $record = $this->recordRepo->findByUuid($uuid); + if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); + } + + return $this->success(array_map( + fn(\App\Patient\Entity\PatientAttachment $a) => $a->toArray(), + $this->attachmentRepo->findByRecord($record) + )); + } + + #[Route('/api/v1/patient/{uuid}/attachment', methods: ['POST'])] + public function uploadAttachment(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $record = $this->recordRepo->findByUuid($uuid); + if ($record === null || !$this->ownsRecord($record, $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_PATIENT_NOT_FOUND, ErrorCodes::message(ErrorCodes::ERR_PATIENT_NOT_FOUND), 404); + } + + try { + $stored = $this->fileUpload->storeFromRequest($request, 'patients/attachments'); + } catch (\RuntimeException $e) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422); + } + + $name = trim((string) $request->query->get('name', '')) ?: $stored['filename']; + $attachment = new \App\Patient\Entity\PatientAttachment($record, $name, $stored['url'], $stored['filemime'], $stored['size']); + $this->attachmentRepo->save($attachment); + + return $this->success($attachment->toArray(), 201); + } + + #[Route('/api/v1/patient/attachment/{uuid}', methods: ['DELETE'])] + public function deleteAttachment(string $uuid, #[CurrentUser] User $user): JsonResponse + { + [$entityType, $entityId] = $this->resolveEntity($user); + $attachment = $this->attachmentRepo->findByUuid($uuid); + if ($attachment === null || !$this->ownsRecord($attachment->getRecord(), $entityType, $entityId)) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'ضمیمه یافت نشد', 404); + } + + $this->attachmentRepo->remove($attachment); + + return $this->success(['message' => 'ضمیمه حذف شد']); + } + /** * Assign record labels from the payload (`tags` = array of TenantTag uuids), * scoped to the caller's entity. Returns a 422 response on a foreign tag, diff --git a/src/Patient/Entity/PatientAttachment.php b/src/Patient/Entity/PatientAttachment.php new file mode 100644 index 00000000..b2de6b44 --- /dev/null +++ b/src/Patient/Entity/PatientAttachment.php @@ -0,0 +1,69 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->record = $record; + $this->name = $name; + $this->url = $url; + $this->mime = $mime; + $this->size = $size; + $this->createdAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getRecord(): PatientRecord { return $this->record; } + public function getUrl(): string { return $this->url; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'name' => $this->name, + 'url' => $this->url, + 'mime' => $this->mime, + 'size' => $this->size, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Patient/Repository/PatientAttachmentRepository.php b/src/Patient/Repository/PatientAttachmentRepository.php new file mode 100644 index 00000000..dc4b0108 --- /dev/null +++ b/src/Patient/Repository/PatientAttachmentRepository.php @@ -0,0 +1,44 @@ +findOneBy(['uuid' => $uuid]); + } + + /** @return PatientAttachment[] */ + public function findByRecord(PatientRecord $record): array + { + return $this->createQueryBuilder('a') + ->where('a.record = :record') + ->setParameter('record', $record) + ->orderBy('a.id', 'DESC') + ->getQuery() + ->getResult(); + } + + public function save(PatientAttachment $a): void + { + $this->getEntityManager()->persist($a); + $this->getEntityManager()->flush(); + } + + public function remove(PatientAttachment $a): void + { + $this->getEntityManager()->remove($a); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Shared/Service/FileUploadService.php b/src/Shared/Service/FileUploadService.php new file mode 100644 index 00000000..c39c1e4b --- /dev/null +++ b/src/Shared/Service/FileUploadService.php @@ -0,0 +1,59 @@ +/-/ and returns its public URL + metadata. + */ +class FileUploadService +{ + public function __construct( + private readonly FileValidatorService $fileValidator, + private readonly string $projectDir, + ) {} + + /** + * @return array{url: string, filename: string, filemime: string, size: int} + * @throws \RuntimeException on an invalid/oversized file + */ + public function storeFromRequest(Request $request, string $subDir): array + { + $content = $request->getContent(); + preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $request->headers->get('Content-Disposition', ''), $m); + $filename = $m[1] ?? 'file'; + + $tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true); + file_put_contents($tmpPath, $content); + + try { + $safeFilename = $this->fileValidator->sanitizeFilename($filename); + $mime = $this->fileValidator->detectMimeType($tmpPath); + + $year = date('Y'); + $month = date('m'); + $dir = $this->projectDir . '/public/uploads/' . $subDir . '/' . $year . '-' . $month; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $storedName = uniqid('', true) . '_' . $safeFilename; + rename($tmpPath, $dir . '/' . $storedName); + + return [ + 'url' => '/uploads/' . $subDir . '/' . $year . '-' . $month . '/' . $storedName, + 'filename' => $safeFilename, + 'filemime' => $mime, + 'size' => strlen($content), + ]; + } catch (\Throwable $e) { + if (file_exists($tmpPath)) { + unlink($tmpPath); + } + throw new \RuntimeException($e->getMessage(), 0, $e); + } + } +} diff --git a/tests/Patient/PatientAttachmentTest.php b/tests/Patient/PatientAttachmentTest.php new file mode 100644 index 00000000..810a48e4 --- /dev/null +++ b/tests/Patient/PatientAttachmentTest.php @@ -0,0 +1,62 @@ +createUser(['ROLE_DOCTOR']); + $doctor = new Doctor($owner, 'دکتر'); + $this->em->persist($doctor); + $this->em->flush(); + + $patient = $this->createUser(['ROLE_USER']); + $record = new PatientRecord('doctor', $doctor->getId(), $patient, 'doctor', $doctor->getId()); + $this->em->persist($record); + $this->em->flush(); + + return [$owner, $record]; + } + + public function testListAndDelete(): void + { + [$owner, $record] = $this->recordFor(); + $att = new PatientAttachment($record, 'آزمایش.pdf', '/uploads/patients/attachments/x.pdf', 'application/pdf', 1234); + $this->em->persist($att); + $this->em->flush(); + $attUuid = $att->getUuid(); + + $list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/attachments', $owner); + self::assertSame(200, $this->responseCode()); + self::assertCount(1, $list['data']); + self::assertSame('آزمایش.pdf', $list['data'][0]['name']); + + $this->authJson('DELETE', '/api/v1/patient/attachment/' . $attUuid, $owner); + self::assertSame(200, $this->responseCode()); + + $after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/attachments', $owner); + self::assertCount(0, $after['data']); + } + + public function testCannotDeleteAnotherTenantsAttachment(): void + { + [, $record] = $this->recordFor(); + $att = new PatientAttachment($record, 'x.pdf', '/uploads/x.pdf'); + $this->em->persist($att); + $this->em->flush(); + + [$otherOwner] = $this->recordFor(); + $this->authJson('DELETE', '/api/v1/patient/attachment/' . $att->getUuid(), $otherOwner); + self::assertSame(404, $this->responseCode()); + } +}