feat(patients): phase B2 — attachments (ضمیمه)

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) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-13 15:20:00 +03:30
co-authored by Claude Opus 4.8
parent 4c52316a49
commit 537bb8c7b3
10 changed files with 446 additions and 2 deletions
@@ -58,4 +58,12 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
fireEvent.click(screen.getByText('پیام‌ها')); fireEvent.click(screen.getByText('پیام‌ها'));
expect(screen.getByText(/به‌زودی تکمیل می‌شود/)).toBeInTheDocument(); 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();
});
}); });
+89 -2
View File
@@ -1,14 +1,17 @@
import { useState } from 'react'; import { useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { import {
ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon, ChevronRightIcon, PencilIcon, ClipboardDocumentCheckIcon, DocumentTextIcon,
CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon, CalendarDaysIcon, CreditCardIcon, BanknotesIcon, ChatBubbleLeftRightIcon,
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon, PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
ArrowUpTrayIcon, TrashIcon, DocumentIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api'; import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api'; import type { ApiResponse } from '../lib/api';
import type { PatientRecord } from '../types'; import type { PatientRecord } from '../types';
import { useAuthStore } from '../stores/authStore';
import { formatDate } from '../lib/utils'; import { formatDate } from '../lib/utils';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records'; type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
@@ -120,6 +123,8 @@ export default function PatientDetailPage() {
) : tab === 'appointments' ? ( ) : tab === 'appointments' ? (
<TabList q={appointmentsQ} emptyLabel="نوبتی ثبت نشده است" <TabList q={appointmentsQ} emptyLabel="نوبتی ثبت نشده است"
row={(a) => ({ 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 })} /> row={(a) => ({ 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' ? (
<AttachmentsTab uuid={uuid!} />
) : ( ) : (
<Placeholder label={TABS.find((t) => t.key === tab)!.label} /> <Placeholder label={TABS.find((t) => 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<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const { data, isLoading } = useQuery<ApiResponse<Attachment[]>>({
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 (
<div>
<div style={{ marginBottom: 14 }}>
<input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
<button className="btn primary" disabled={uploading} onClick={() => fileRef.current?.click()}>
<ArrowUpTrayIcon style={{ width: 16 }} /> {uploading ? 'در حال آپلود...' : 'آپلود فایل جدید'}
</button>
</div>
{isLoading ? (
<div style={{ padding: 16, color: 'var(--text-3)', fontSize: 13 }}>در حال بارگذاری...</div>
) : items.length === 0 ? (
<div style={{ padding: '40px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 14 }}>هنوز فایلی ضمیمه نشده است.</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((a) => (
<div key={a.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<DocumentIcon style={{ width: 22, color: 'var(--primary)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<a href={a.url} target="_blank" rel="noreferrer" style={{ fontWeight: 600, fontSize: 14, color: 'var(--text)', textDecoration: 'none', wordBreak: 'break-all' }}>{a.name}</a>
{a.size ? <div style={{ fontSize: 12, color: 'var(--text-3)' }}>{formatBytes(a.size)}</div> : null}
</div>
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => del.mutate(a.uuid)}><TrashIcon style={{ width: 16 }} /></button>
</div>
))}
</div>
)}
</div>
);
}
function TabList({ q, emptyLabel, row }: { function TabList({ q, emptyLabel, row }: {
q: { data?: ApiResponse<any[]>; isLoading: boolean }; q: { data?: ApiResponse<any[]>; isLoading: boolean };
emptyLabel: string; emptyLabel: string;
+4
View File
@@ -28,6 +28,10 @@ services:
arguments: arguments:
$environment: '%kernel.environment%' $environment: '%kernel.environment%'
App\Shared\Service\FileUploadService:
arguments:
$projectDir: '%kernel.project_dir%'
App\Doctor\Controller\DoctorController: App\Doctor\Controller\DoctorController:
arguments: arguments:
$projectDir: '%kernel.project_dir%' $projectDir: '%kernel.project_dir%'
+23
View File
@@ -483,3 +483,26 @@ When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointme
**انتساب پزشک:** هر `PatientSession` در پاسخ، `doctor_uuid` و `doctor_name` را از روی نوبتِ متناظر برمی‌گرداند؛ پس در پرونده‌ی کلینیک مشخص است هر مراجعه برای کدام پزشک بوده است. **انتساب پزشک:** هر `PatientSession` در پاسخ، `doctor_uuid` و `doctor_name` را از روی نوبتِ متناظر برمی‌گرداند؛ پس در پرونده‌ی کلینیک مشخص است هر مراجعه برای کدام پزشک بوده است.
**آدرس نوبت:** هنگام رزرو، `address_id` خودکار از `location_id` همان session برنامه‌ی هفتگی ست می‌شود (در همه‌ی مسیرهای رزرو). ثبت `location_id` برای هر شیفت فعال در برنامه‌ی هفتگی الزامی است (`POST/PATCH /api/v1/appointment-settings/weekly-schedule`)؛ در غیر این صورت `422`. **آدرس نوبت:** هنگام رزرو، `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` | فایل نامعتبر |
+33
View File
@@ -0,0 +1,33 @@
<?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 Version20260713114523 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('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');
}
}
@@ -50,9 +50,64 @@ class PatientController extends BaseController
private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo, private readonly \App\Appointment\Repository\AppointmentRepository $appointmentRepo,
private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo, private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo,
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo, 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, 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), * 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, * scoped to the caller's entity. Returns a 422 response on a foreign tag,
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Patient\Entity;
use App\Patient\Repository\PatientAttachmentRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/** A file attached to a patient record (the «ضمیمه» tab). */
#[ORM\Entity(repositoryClass: PatientAttachmentRepository::class)]
#[ORM\Table(name: 'patient_attachments')]
#[ORM\Index(columns: ['record_id'], name: 'idx_patient_attachments_record')]
class PatientAttachment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 36, unique: true)]
private string $uuid;
#[ORM\ManyToOne(targetEntity: PatientRecord::class)]
#[ORM\JoinColumn(name: 'record_id', nullable: false, onDelete: 'CASCADE')]
private PatientRecord $record;
#[ORM\Column(type: 'string', length: 200)]
private string $name;
#[ORM\Column(type: 'string', length: 500)]
private string $url;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $mime = null;
#[ORM\Column(type: 'integer', nullable: true)]
private ?int $size = null;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(PatientRecord $record, string $name, string $url, ?string $mime = null, ?int $size = null)
{
$this->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,
];
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Patient\Repository;
use App\Patient\Entity\PatientAttachment;
use App\Patient\Entity\PatientRecord;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PatientAttachmentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PatientAttachment::class);
}
public function findByUuid(string $uuid): ?PatientAttachment
{
return $this->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();
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Shared\Service;
use Symfony\Component\HttpFoundation\Request;
/**
* Stores a raw-body file upload (the `/file/upload/...` convention: the file is
* sent as the request body with a Content-Disposition filename) under
* public/uploads/<subDir>/<year>-<month>/ 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);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Tests\Patient;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientAttachment;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* Patient attachments: list, delete, and tenant ownership scoping.
*/
class PatientAttachmentTest extends ApiTestCase
{
/** @return array{0: \App\Auth\Entity\User, 1: PatientRecord} */
private function recordFor(): array
{
$owner = $this->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());
}
}