feat(patients): phase B3 — medical records (پرونده پزشکی)

Add patient medical-exam entries: a new PatientMedicalRecord entity
(record-scoped, CASCADE) + repository, and owner-scoped CRUD endpoints
(GET list, POST create, PATCH, DELETE) under /api/v1/patient. Wire the
"پرونده پزشکی" tab in PatientDetailPage (list + add/edit modal with title,
date and notes + delete). PHPUnit covers CRUD + ownership + validation;
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:27:34 +03:30
co-authored by Claude Opus 4.8
parent 98943ac249
commit 293eb8a0d2
8 changed files with 450 additions and 0 deletions
@@ -66,4 +66,13 @@ describe('PatientDetailPage (پرونده تب‌دار)', () => {
expect(await screen.findByRole('button', { name: /آپلود فایل جدید/ })).toBeInTheDocument();
expect(await screen.findByText('هنوز فایلی ضمیمه نشده است.')).toBeInTheDocument();
});
it('opens the add-exam modal on the medical-record tab', async () => {
renderDetail();
await screen.findByText('ساغر صابری');
fireEvent.click(screen.getByText('پرونده پزشکی'));
fireEvent.click(await screen.findByRole('button', { name: /ثبت معاینه جدید/ }));
// modal opens with a title field
expect(await screen.findByPlaceholderText('مثلاً: معاینه اولیه')).toBeInTheDocument();
});
});
+110
View File
@@ -7,12 +7,16 @@ import {
PhoneArrowUpRightIcon, PaperClipIcon, ClipboardDocumentListIcon,
ArrowUpTrayIcon, TrashIcon, DocumentIcon,
} from '@heroicons/react/24/outline';
import { PlusIcon } 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';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import PersianDateInput from '../components/ui/PersianDateInput';
type TabKey = 'services' | 'info' | 'appointments' | 'payments' | 'wallet' | 'messages' | 'callcenter' | 'attach' | 'records';
@@ -125,6 +129,8 @@ export default function PatientDetailPage() {
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!} />
) : tab === 'records' ? (
<MedicalRecordsTab uuid={uuid!} />
) : (
<Placeholder label={TABS.find((t) => t.key === tab)!.label} />
)}
@@ -214,6 +220,110 @@ function AttachmentsTab({ uuid }: { uuid: string }) {
);
}
interface MedicalItem { uuid: string; title: string; body?: string | null; recorded_at: number }
/** پرونده پزشکی — medical exam entries: list + add/edit modal + delete. */
function MedicalRecordsTab({ uuid }: { uuid: string }) {
const qc = useQueryClient();
const [modal, setModal] = useState<'create' | MedicalItem | null>(null);
const [delTarget, setDelTarget] = useState<MedicalItem | null>(null);
const [title, setTitle] = useState('');
const [date, setDate] = useState('');
const [body, setBody] = useState('');
const { data, isLoading } = useQuery<ApiResponse<MedicalItem[]>>({
queryKey: ['patient-medical', uuid],
queryFn: () => api.get(`/api/v1/patient/${uuid}/medical-records`),
});
const items = data?.data ?? [];
const invalidate = () => qc.invalidateQueries({ queryKey: ['patient-medical', uuid] });
const open = (m?: MedicalItem) => {
setTitle(m?.title ?? ''); setBody(m?.body ?? '');
setDate(m?.recorded_at ? new Date(m.recorded_at * 1000).toISOString().slice(0, 10) : '');
setModal(m ?? 'create');
};
const save = useMutation({
mutationFn: () => {
const payload = { title, body: body || null, recorded_at: date ? Math.floor(new Date(date).getTime() / 1000) : undefined };
return modal === 'create'
? api.post(`/api/v1/patient/${uuid}/medical-record`, payload)
: api.patch(`/api/v1/patient/medical-record/${(modal as MedicalItem).uuid}`, payload);
},
onSuccess: () => { invalidate(); setModal(null); toast.success('ذخیره شد'); },
onError: (e: any) => toast.error(e.message),
});
const del = useMutation({
mutationFn: (u: string) => api.delete(`/api/v1/patient/medical-record/${u}`),
onSuccess: () => { invalidate(); setDelTarget(null); toast.success('حذف شد'); },
onError: (e: any) => { toast.error(e.message); setDelTarget(null); },
});
return (
<div>
<div style={{ marginBottom: 14 }}>
<button className="btn primary" onClick={() => open()}><PlusIcon style={{ width: 16 }} /> ثبت معاینه جدید</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: 10 }}>
{items.map((m) => (
<div key={m.uuid} style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 'var(--r-lg)', padding: '14px 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 700, fontSize: 14 }}>{m.title}</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>{formatDate(m.recorded_at)}</div>
{m.body && <div style={{ fontSize: 13, color: 'var(--text-2)', marginTop: 8, whiteSpace: 'pre-wrap' }}>{m.body}</div>}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn sm ghost" aria-label="ویرایش" style={{ color: 'var(--accent)' }} onClick={() => open(m)}><PencilIcon style={{ width: 15 }} /></button>
<button className="btn sm ghost" aria-label="حذف" style={{ color: 'var(--danger)' }} onClick={() => setDelTarget(m)}><TrashIcon style={{ width: 15 }} /></button>
</div>
</div>
</div>
))}
</div>
)}
<Modal open={modal !== null} onClose={() => setModal(null)} title={modal === 'create' ? 'ثبت معاینه جدید' : 'ویرایش معاینه'}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label className="field-label">عنوان *</label>
<div className="field"><input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="مثلاً: معاینه اولیه" autoFocus /></div>
</div>
<div>
<label className="field-label">تاریخ</label>
<PersianDateInput value={date} onChange={setDate} />
</div>
<div>
<label className="field-label">شرح</label>
<div className="field" style={{ height: 'auto' }}><textarea value={body} onChange={(e) => setBody(e.target.value)} rows={4} placeholder="شرح معاینه" style={{ width: '100%', border: 'none', background: 'transparent', fontFamily: 'inherit', resize: 'vertical' }} /></div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={!title.trim() || save.isPending} onClick={() => save.mutate()}>ذخیره</button>
<button className="btn" onClick={() => setModal(null)}>انصراف</button>
</div>
</div>
</Modal>
<ConfirmDialog
open={!!delTarget}
title="حذف معاینه"
message={`آیا از حذف «${delTarget?.title}» مطمئن هستید؟`}
confirmLabel="حذف"
onConfirm={() => delTarget && del.mutate(delTarget.uuid)}
onCancel={() => setDelTarget(null)}
loading={del.isPending}
/>
</div>
);
}
function TabList({ q, emptyLabel, row }: {
q: { data?: ApiResponse<any[]>; isLoading: boolean };
emptyLabel: string;
+29
View File
@@ -506,3 +506,32 @@ When an appointment's status changes to `confirmed` via `PATCH /api/v1/appointme
|------|------|-------------|
| 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/ضمیمه یافت نشد یا متعلق به tenant دیگر |
| 422 | `ERR_VALIDATION_001` | فایل نامعتبر |
---
## پرونده پزشکی (Medical Records)
معاینات/یادداشت‌های پزشکیِ یک پرونده. scope به رکورد و tenant صاحب رکورد.
**Permission:** `IS_AUTHENTICATED_FULLY` (مالک رکورد)
### GET `/api/v1/patient/{uuid}/medical-records`
لیست (مرتب بر اساس `recorded_at` نزولی). Response: `{ success, data: [{ uuid, title, body, recorded_at, created_at }] }`
### POST `/api/v1/patient/{uuid}/medical-record`
```json
{ "title": "معاینه اولیه", "body": "شرح (اختیاری)", "recorded_at": 1700000000 }
```
`title` الزامی؛ `recorded_at` اختیاری (پیش‌فرض زمان ثبت). Response `201`.
### PATCH `/api/v1/patient/medical-record/{uuid}`
فیلدهای اختیاری `title` / `body` / `recorded_at`. فقط مالک؛ در غیر این صورت `404`.
### DELETE `/api/v1/patient/medical-record/{uuid}`
حذف. فقط مالک؛ در غیر این صورت `404`.
### Errors
| HTTP | Code | Description |
|------|------|-------------|
| 422 | `ERR_VALIDATION_001` | عنوان خالی (`field: title`) |
| 404 | `ERR_PATIENT_001` / `ERR_NOT_FOUND_001` | رکورد/رکورد پزشکی یافت نشد یا tenant دیگر |
+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 Version20260713115316 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_medical_records (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, title VARCHAR(200) NOT NULL, body LONGTEXT DEFAULT NULL, recorded_at INT NOT NULL, created_at INT NOT NULL, record_id INT NOT NULL, UNIQUE INDEX UNIQ_3B2B3121D17F50A6 (uuid), INDEX idx_pmr_record (record_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
$this->addSql('ALTER TABLE patient_medical_records ADD CONSTRAINT FK_3B2B31214DFD750C 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_medical_records DROP FOREIGN KEY FK_3B2B31214DFD750C');
$this->addSql('DROP TABLE patient_medical_records');
}
}
@@ -51,10 +51,96 @@ class PatientController extends BaseController
private readonly \App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository $invitationRepo,
private readonly \App\Tag\Repository\TenantTagRepository $tenantTagRepo,
private readonly \App\Patient\Repository\PatientAttachmentRepository $attachmentRepo,
private readonly \App\Patient\Repository\PatientMedicalRecordRepository $medicalRepo,
private readonly \App\Shared\Service\FileUploadService $fileUpload,
private readonly LoggerInterface $logger,
) {}
// ── Medical records (پرونده پزشکی) ────────────────────────────────────────
#[Route('/api/v1/patient/{uuid}/medical-records', methods: ['GET'])]
public function listMedicalRecords(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\PatientMedicalRecord $m) => $m->toArray(),
$this->medicalRepo->findByRecord($record)
));
}
#[Route('/api/v1/patient/{uuid}/medical-record', methods: ['POST'])]
public function createMedicalRecord(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);
}
$data = json_decode($request->getContent(), true) ?? [];
$title = trim((string) ($data['title'] ?? ''));
if ($title === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'عنوان معاینه الزامی است', 422, 'title');
}
$body = isset($data['body']) ? trim((string) $data['body']) : null;
$recordedAt = isset($data['recorded_at']) && $data['recorded_at'] !== '' ? (int) $data['recorded_at'] : null;
$medical = new \App\Patient\Entity\PatientMedicalRecord($record, $title, $body ?: null, $recordedAt);
$this->medicalRepo->save($medical);
return $this->success($medical->toArray(), 201);
}
#[Route('/api/v1/patient/medical-record/{uuid}', methods: ['PATCH'])]
public function updateMedicalRecord(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'رکورد پزشکی یافت نشد', 404);
}
$data = json_decode($request->getContent(), true) ?? [];
if (isset($data['title'])) {
$t = trim((string) $data['title']);
if ($t === '') {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'عنوان معاینه الزامی است', 422, 'title');
}
$medical->setTitle($t);
}
if (array_key_exists('body', $data)) {
$b = trim((string) ($data['body'] ?? ''));
$medical->setBody($b === '' ? null : $b);
}
if (isset($data['recorded_at']) && $data['recorded_at'] !== '') {
$medical->setRecordedAt((int) $data['recorded_at']);
}
$this->medicalRepo->save($medical);
return $this->success($medical->toArray());
}
#[Route('/api/v1/patient/medical-record/{uuid}', methods: ['DELETE'])]
public function deleteMedicalRecord(string $uuid, #[CurrentUser] User $user): JsonResponse
{
[$entityType, $entityId] = $this->resolveEntity($user);
$medical = $this->medicalRepo->findByUuid($uuid);
if ($medical === null || !$this->ownsRecord($medical->getRecord(), $entityType, $entityId)) {
return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'رکورد پزشکی یافت نشد', 404);
}
$this->medicalRepo->remove($medical);
return $this->success(['message' => 'رکورد پزشکی حذف شد']);
}
// ── Attachments (ضمیمه) ───────────────────────────────────────────────────
#[Route('/api/v1/patient/{uuid}/attachments', methods: ['GET'])]
@@ -0,0 +1,68 @@
<?php
namespace App\Patient\Entity;
use App\Patient\Repository\PatientMedicalRecordRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/** A medical examination / note entry on a patient record (the «پرونده پزشکی» tab). */
#[ORM\Entity(repositoryClass: PatientMedicalRecordRepository::class)]
#[ORM\Table(name: 'patient_medical_records')]
#[ORM\Index(columns: ['record_id'], name: 'idx_pmr_record')]
class PatientMedicalRecord
{
#[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 $title;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $body = null;
/** Exam date (Unix ts); defaults to creation time. */
#[ORM\Column(name: 'recorded_at', type: 'integer')]
private int $recordedAt;
#[ORM\Column(name: 'created_at', type: 'integer')]
private int $createdAt;
public function __construct(PatientRecord $record, string $title, ?string $body = null, ?int $recordedAt = null)
{
$this->uuid = Uuid::v4()->toRfc4122();
$this->record = $record;
$this->title = $title;
$this->body = $body;
$this->createdAt = time();
$this->recordedAt = $recordedAt ?? $this->createdAt;
}
public function getId(): ?int { return $this->id; }
public function getUuid(): string { return $this->uuid; }
public function getRecord(): PatientRecord { return $this->record; }
public function setTitle(string $v): self { $this->title = $v; return $this; }
public function setBody(?string $v): self { $this->body = $v; return $this; }
public function setRecordedAt(int $v): self { $this->recordedAt = $v; return $this; }
public function toArray(): array
{
return [
'uuid' => $this->uuid,
'title' => $this->title,
'body' => $this->body,
'recorded_at' => $this->recordedAt,
'created_at' => $this->createdAt,
];
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Patient\Repository;
use App\Patient\Entity\PatientMedicalRecord;
use App\Patient\Entity\PatientRecord;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PatientMedicalRecordRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, PatientMedicalRecord::class);
}
public function findByUuid(string $uuid): ?PatientMedicalRecord
{
return $this->findOneBy(['uuid' => $uuid]);
}
/** @return PatientMedicalRecord[] */
public function findByRecord(PatientRecord $record): array
{
return $this->createQueryBuilder('m')
->where('m.record = :record')
->setParameter('record', $record)
->orderBy('m.recordedAt', 'DESC')
->getQuery()
->getResult();
}
public function save(PatientMedicalRecord $m): void
{
$this->getEntityManager()->persist($m);
$this->getEntityManager()->flush();
}
public function remove(PatientMedicalRecord $m): void
{
$this->getEntityManager()->remove($m);
$this->getEntityManager()->flush();
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Tests\Patient;
use App\Doctor\Entity\Doctor;
use App\Patient\Entity\PatientRecord;
use App\Tests\ApiTestCase;
/**
* Patient medical records: CRUD + tenant ownership scoping.
*/
class PatientMedicalRecordTest 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 testCrud(): void
{
[$owner, $record] = $this->recordFor();
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, [
'title' => 'معاینه اولیه', 'body' => 'فشار خون طبیعی', 'recorded_at' => 1_700_000_000,
]);
self::assertSame(201, $this->responseCode());
self::assertSame('معاینه اولیه', $created['data']['title']);
$mUuid = $created['data']['uuid'];
$list = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/medical-records', $owner);
self::assertCount(1, $list['data']);
$this->authJson('PATCH', '/api/v1/patient/medical-record/' . $mUuid, $owner, ['title' => 'معاینه دوم']);
self::assertSame(200, $this->responseCode());
$this->authJson('DELETE', '/api/v1/patient/medical-record/' . $mUuid, $owner);
self::assertSame(200, $this->responseCode());
$after = $this->authJson('GET', '/api/v1/patient/' . $record->getUuid() . '/medical-records', $owner);
self::assertCount(0, $after['data']);
}
public function testRequiresTitle(): void
{
[$owner, $record] = $this->recordFor();
$this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, ['title' => '']);
self::assertSame(422, $this->responseCode());
}
public function testOwnershipScoped(): void
{
[$owner, $record] = $this->recordFor();
$created = $this->authJson('POST', '/api/v1/patient/' . $record->getUuid() . '/medical-record', $owner, ['title' => 'x']);
$mUuid = $created['data']['uuid'];
[$other] = $this->recordFor();
$this->authJson('DELETE', '/api/v1/patient/medical-record/' . $mUuid, $other);
self::assertSame(404, $this->responseCode());
}
}