Task 01 planned a new `branches` table with `doctor_addresses.branch_id` bridging to it. That plan was wrong: the branch already exists and is called `DoctorAddress`. It carries name, address, telephone, coordinates, city/province FKs and an owner (`forDoctor` / `forClinic` + `type`), and the whole system already consumes it with exactly that meaning — `WeeklySchedule.sessions[].location_id` points at `doctor_addresses.id`, `appointment-booking-locations` calls each row a booking location, and nine CRUD endpoints plus four admin pages manage them. A parallel table would mean two sources of truth for one physical place and a branch that `location_id` never references. So no `branches` table and no duplicate branch CRUD. Only the three genuinely missing pieces: - `doctor_addresses.active` / `.timezone`, both NOT NULL with a default so existing rows need no backfill and no current behaviour changes. `active` is stored only — applying it to slot calculation is task 03, since touching `SlotCalculatorService` is off limits in this phase. - `branch_working_hours`, keyed to `doctor_addresses.id`. Minutes from midnight rather than "09:00" strings so range intersection stays arithmetic. PUT replaces all seven days; validation of the whole week runs before any DELETE, so an invalid sixth day cannot wipe the five valid ones and then answer 422. - `rooms`, with `capacity` as concurrency (a three-bed injection room is one resource with capacity 3, not three resources) and a deletion-guard iterator so tasks 02 and 07 can add reasons without editing RoomService. `BranchWorkingHours` first registered as an aggregate child of `DoctorAddress`; TenantSchemaCoverageTest rejected it correctly, because that root is itself declared global. It now carries a real tenant pair instead, derived in the constructor from the address's `type` — a total mapping, and the address is only ever listed in its own context, so nothing is hidden wrongly. RoomController checks ownership explicitly rather than trusting TenantFilter: hard isolation only applies to a *chosen* context, so a doctor who had not selected one could PATCH another clinic's room. Caught by RoomCrudTest::testForeignRoomIsNotFound, which failed with 200 before the fix. 35 tests, 97 assertions. Slot-mode frozen contract still green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
121 lines
4.8 KiB
PHP
121 lines
4.8 KiB
PHP
<?php
|
||
|
||
namespace App\Branch\Controller;
|
||
|
||
use App\Auth\Entity\User;
|
||
use App\Branch\Entity\Room;
|
||
use App\Branch\Repository\RoomRepository;
|
||
use App\Branch\Service\BranchResolver;
|
||
use App\Branch\Service\RoomService;
|
||
use App\Clinic\Security\ClinicDoctorAccessChecker;
|
||
use App\Secretary\Security\SecretaryAccessChecker;
|
||
use App\Shared\Constant\ErrorCodes;
|
||
use App\Shared\Controller\BaseController;
|
||
use App\Shared\Context\EntityContext;
|
||
use App\Shared\Exception\AppException;
|
||
use App\Shared\Tenant\TenantOwnershipChecker;
|
||
use OpenApi\Attributes as OA;
|
||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||
use Symfony\Component\HttpFoundation\Request;
|
||
use Symfony\Component\Routing\Attribute\Route;
|
||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||
|
||
#[OA\Tag(name: 'Branch')]
|
||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||
class RoomController extends BaseController
|
||
{
|
||
public function __construct(
|
||
private readonly BranchResolver $branches,
|
||
private readonly RoomRepository $rooms,
|
||
private readonly RoomService $roomService,
|
||
private readonly TenantOwnershipChecker $ownership,
|
||
private readonly SecretaryAccessChecker $secretaryAccess,
|
||
private readonly ClinicDoctorAccessChecker $clinicDoctorAccess,
|
||
) {}
|
||
|
||
/** @param 'view'|'update' $action */
|
||
private function denyUnlessGranted(User $user, string $action): void
|
||
{
|
||
$this->secretaryAccess->denyUnlessGranted($user, 'appointment_settings', $action);
|
||
$this->clinicDoctorAccess->denyUnlessGranted($user, 'appointment_settings', $action);
|
||
}
|
||
|
||
/**
|
||
* مالکیت صریح سنجیده میشود و به TenantFilter تکیه نمیکنیم: جداسازی سختِ فیلتر
|
||
* فقط روی محیطِ «انتخابشده» اعمال میشود ({@see EntityContext::$chosen}) و پزشکی
|
||
* که هنوز محیطی برنگزیده، اتاق کلینیک دیگر را میدید — با تست
|
||
* RoomCrudTest::testForeignRoomIsNotFound گرفته شد.
|
||
*
|
||
* ۴۰۴ نه ۴۰۳، همان رفتار فیلتر: وجود دادهٔ محیط بیگانه لو نمیرود.
|
||
*/
|
||
private function requireRoom(User $user, string $uuid): Room
|
||
{
|
||
$room = $this->rooms->findByUuid($uuid);
|
||
[$entityType, $entityId] = $this->branches->pair($user);
|
||
|
||
if ($room === null || !$this->ownership->belongsToPair($entityType, $entityId, $room)) {
|
||
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'اتاق یافت نشد', 404);
|
||
}
|
||
|
||
return $room;
|
||
}
|
||
|
||
#[Route('/api/v1/branch/{addressUuid}/rooms', name: 'branch_rooms_list', methods: ['GET'])]
|
||
public function list(#[CurrentUser] User $user, string $addressUuid): JsonResponse
|
||
{
|
||
$this->denyUnlessGranted($user, 'view');
|
||
|
||
$address = $this->branches->resolve($user, $addressUuid);
|
||
|
||
return $this->success(array_map(
|
||
static fn (Room $room): array => $room->toArray(),
|
||
$this->rooms->findForAddress($address),
|
||
));
|
||
}
|
||
|
||
#[Route('/api/v1/room', name: 'room_create', methods: ['POST'])]
|
||
public function create(#[CurrentUser] User $user, Request $request): JsonResponse
|
||
{
|
||
$this->denyUnlessGranted($user, 'update');
|
||
|
||
$data = json_decode($request->getContent(), true);
|
||
|
||
if (!is_array($data) || !is_string($data['address_uuid'] ?? null)) {
|
||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'فیلد address_uuid الزامی است', 422, 'address_uuid');
|
||
}
|
||
|
||
// جفت محیط اتاق از همین آدرس مشتق میشود، نه از بدنهٔ درخواست.
|
||
$address = $this->branches->resolve($user, $data['address_uuid']);
|
||
$room = $this->roomService->create($address, $data);
|
||
|
||
return $this->success($room->toArray(), 201);
|
||
}
|
||
|
||
#[Route('/api/v1/room/{uuid}', name: 'room_update', methods: ['PATCH'])]
|
||
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
|
||
{
|
||
$this->denyUnlessGranted($user, 'update');
|
||
|
||
$data = json_decode($request->getContent(), true);
|
||
|
||
if (!is_array($data)) {
|
||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
|
||
}
|
||
|
||
$room = $this->roomService->update($this->requireRoom($user, $uuid), $data);
|
||
|
||
return $this->success($room->toArray());
|
||
}
|
||
|
||
#[Route('/api/v1/room/{uuid}', name: 'room_delete', methods: ['DELETE'])]
|
||
public function delete(#[CurrentUser] User $user, string $uuid): JsonResponse
|
||
{
|
||
$this->denyUnlessGranted($user, 'update');
|
||
|
||
$this->roomService->delete($this->requireRoom($user, $uuid));
|
||
|
||
return $this->success(null);
|
||
}
|
||
}
|