Files
clinicpro/src/Branch/Controller/RoomController.php
T
hamedandClaude Opus 5 eebb363b9f feat(branch): branch working hours and rooms on the existing address entity
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>
2026-07-30 16:28:04 +03:30

121 lines
4.8 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}