feat(tenant): mark the booking tables with their owning environment

Phase 2 of the tenant-marking series. appointments, weekly_schedules and
date_overrides kept their environment implicit in a nullable clinic_id, so
every query that wanted "this environment's rows" had to rebuild
clinic_id IS NULL ? doctor : clinic itself. The two calendar tables also
depended on a MariaDB-only generated column, clinic_key = IFNULL(clinic_id, 0),
purely to make a unique key work across NULLs.

All three now carry the (entity_type, entity_id) pair that service_sections,
patient_records and clinic_staff already use, via a shared TenantOwnedTrait.
The pair is a deliberate denormalisation of clinic_id/doctor_id: the automatic
tenant filter and the tenant-leading indexes both need a real column, and
neither can be built on an IF() expression.

- Unique keys keep doctor_id alongside the pair. A clinic has several doctors
  and each has their own schedule, so (entity_type, entity_id) alone would
  reject the second doctor.
- clinic_key is gone from both calendar tables.
- appointments gained tenant-leading indexes; EXPLAIN on the panel's list query
  now picks idx_appointments_tenant_slot.

Deliberately unchanged, both with the reason already recorded in the code:
active_slot_key stays keyed on doctor + slot, since adding the environment
would let one doctor be booked in their own practice and a clinic at the same
moment. holidays keeps its nullable clinic_id, where NULL means "every
environment" rather than "personal practice" — a meaning the pair cannot carry.

The migration adds the columns nullable, backfills, aborts if any row is left
without an owner, and only then tightens to NOT NULL. It creates each
replacement unique index before dropping the old one, so the tables are never
left unprotected — MariaDB commits implicitly on DDL, so ordering is the only
safety net. It runs its statements through the connection rather than addSql()
because the guard has to sit between the backfill and the NOT NULL change.

Columns are NOT NULL with no default on purpose: a construction site that
forgets assignTenant() fails at flush instead of silently writing entity_id 0,
which the phase 4 filter would then hide from everyone.

Verified on the dev database: 0 rows without a tenant, 0 personal bookings
mismatched against their doctor, 0 clinic bookings mismatched against their
clinic.

Tests: 819 passing (813 + 6 new in BookingTenantTest). PHPStan clean on every
changed file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 11:22:37 +03:30
co-authored by Claude Opus 5
parent 1a7bf53577
commit d53874ff50
42 changed files with 455 additions and 69 deletions
@@ -54,8 +54,7 @@ class AppointmentConfirmFlowTest extends ApiTestCase
// اسلات یکتا به‌ازای هر نوبت: db_test بین اجراها پاک نمی‌شود.
$start = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $patient, $start, $start + 900);
$appointment->setClinic($clinic);
$appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
$appointment->setVisitPriceRials($visitPriceRials);
$this->em->persist($appointment);
$this->em->flush();
@@ -35,7 +35,7 @@ class AppointmentConfirmInsuranceSharesTest extends ApiTestCase
private function makeAppointment(Doctor $doctor): Appointment
{
$start = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900);
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900);
$appointment->setVisitPriceRials(self::VISIT_RIALS);
$this->em->persist($appointment);
$this->em->flush();
@@ -27,7 +27,7 @@ class AppointmentExpiryServiceTest extends ApiTestCase
$patient = $this->createUser(['ROLE_USER']);
// distinct past slots — one live booking per (doctor, slot)
$slotStart = $past - $i * 1000;
$appt = new Appointment($doctor, $patient, $slotStart, $slotStart + 900);
$appt = $this->newAppointment($doctor, $patient, $slotStart, $slotStart + 900);
// مثل مسیر واقعیِ رزرو آنلاین: نگه‌داشتِ موقت تا پرداخت درگاه.
$appt->markPendingWithTtl(-1);
$this->em->persist($appt);
@@ -67,7 +67,7 @@ class AppointmentExpiryServiceTest extends ApiTestCase
$this->em->persist($doctor);
$slotStart = time() - 7200;
$appt = new Appointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 900);
$appt = $this->newAppointment($doctor, $this->createUser(['ROLE_USER']), $slotStart, $slotStart + 900);
$this->em->persist($appt);
$this->em->flush();
@@ -31,7 +31,7 @@ class AppointmentInsuranceSelectionTest extends ApiTestCase
{
// اسلات یکتا به‌ازای هر نوبت: db_test بین اجراها پاک نمی‌شود.
$start = strtotime('+30 days') + random_int(0, 500_000) * 7;
$appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900);
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900);
$appointment->setVisitPriceRials(5_952_000);
$this->em->persist($appointment);
$this->em->flush();
+2 -2
View File
@@ -23,7 +23,7 @@ class AppointmentUpdateTest extends ApiTestCase
$this->em->persist($doctor);
$this->em->flush();
$appointment = new Appointment($doctor, $this->createUser(), time() + 86_400, time() + 86_400 + 1_800);
$appointment = $this->newAppointment($doctor, $this->createUser(), time() + 86_400, time() + 86_400 + 1_800);
$this->em->persist($appointment);
$this->em->flush();
@@ -77,7 +77,7 @@ class AppointmentUpdateTest extends ApiTestCase
[$owner, $doctor, $appointment] = $this->booking();
$otherStart = time() + 3 * 86_400;
$this->em->persist(new Appointment($doctor, $this->createUser(), $otherStart, $otherStart + 1_800));
$this->em->persist($this->newAppointment($doctor, $this->createUser(), $otherStart, $otherStart + 1_800));
$this->em->flush();
$this->authJson('PATCH', '/api/v1/appointment/' . $appointment->getUuid(), $owner, [
@@ -24,7 +24,7 @@ class AppointmentWorkflowFieldsTest extends ApiTestCase
private function newBooking(Doctor $doctor, int $start): Appointment
{
$a = new Appointment($doctor, $this->createUser(), $start, $start + 1_800);
$a = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
$this->em->persist($a);
$this->em->flush();
@@ -50,7 +50,7 @@ class BookingLocationValidityTest extends ApiTestCase
'duration_per_patient' => 20,
], fn($v) => $v !== null)]];
$schedule = new WeeklySchedule(
$schedule = $this->newWeeklySchedule(
$doctor,
array_fill_keys(array_map('strval', range(0, 6)), $day),
$clinic
@@ -128,7 +128,7 @@ class BookingLocationValidityTest extends ApiTestCase
]]];
$setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]);
$setting['0'] = $active;
$this->em->persist(new WeeklySchedule($doctor, $setting));
$this->em->persist($this->newWeeklySchedule($doctor, $setting));
$this->em->flush();
$saturday = $this->nextWeekday(6); // date('w'): 6 = Saturday
@@ -165,7 +165,7 @@ class BookingLocationValidityTest extends ApiTestCase
$this->em->flush();
$setting = array_fill_keys(array_map('strval', range(0, 6)), ['sessions' => []]);
$this->em->persist(new WeeklySchedule($doctor, $setting));
$this->em->persist($this->newWeeklySchedule($doctor, $setting));
$this->em->flush();
$body = $this->authJson('GET', "/api/v1/appointment-slots?doctor_uuid={$doctor->getUuid()}&date={$today}", $doctor->getUser());
@@ -43,7 +43,7 @@ class BookingLocationsScanTest extends ApiTestCase
$this->em->persist($personalAddress);
$this->em->flush();
$this->em->persist(new WeeklySchedule(
$this->em->persist($this->newWeeklySchedule(
$doctor,
$this->weekOfSessions($personalAddress->getId(), '09:00', '13:00')
));
@@ -59,7 +59,7 @@ class BookingLocationsScanTest extends ApiTestCase
$this->em->persist($address);
$this->em->flush();
$this->em->persist(new WeeklySchedule(
$this->em->persist($this->newWeeklySchedule(
$doctor,
$this->weekOfSessions($address->getId(), '16:00', '20:00'),
$clinic
@@ -29,7 +29,7 @@ class BookingServicesPublicTest extends ApiTestCase
$this->em->persist($bookable);
$this->em->persist($hidden);
$schedule = new WeeklySchedule($doctor, []);
$schedule = $this->newWeeklySchedule($doctor, []);
$schedule->setMeta(['booking_mode' => 'service', 'buffer_minutes' => 5]);
$this->em->persist($schedule);
$this->em->flush();
+137
View File
@@ -0,0 +1,137 @@
<?php
namespace App\Tests\Appointment;
use App\Appointment\Entity\Appointment;
use App\Appointment\Entity\WeeklySchedule;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Shared\Context\EntityContext;
use App\Tests\ApiTestCase;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
/**
* Phase 2 of tenant marking: appointments, weekly schedules and date overrides
* carry their owning environment as the (entity_type, entity_id) pair rather
* than leaving it implicit in a nullable clinic_id.
*/
class BookingTenantTest extends ApiTestCase
{
private function makeDoctor(): Doctor
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تست محیط');
$this->em->persist($doctor);
$this->em->flush();
return $doctor;
}
private function makeClinic(): Clinic
{
$clinic = new Clinic($this->createUser(['ROLE_CLINIC']));
$clinic->setName('کلینیک تست محیط');
$this->em->persist($clinic);
$this->em->flush();
return $clinic;
}
private function uniqueSlot(): int
{
return strtotime('+90 days') + random_int(0, 500_000) * 7;
}
public function testPersonalBookingIsOwnedByTheDoctor(): void
{
$doctor = $this->makeDoctor();
$start = $this->uniqueSlot();
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900);
$this->em->persist($appointment);
$this->em->flush();
self::assertSame('doctor', $appointment->getEntityType());
self::assertSame($doctor->getId(), $appointment->getEntityId());
}
public function testClinicBookingIsOwnedByTheClinic(): void
{
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$clinic->getDoctors()->add($doctor);
$this->em->flush();
$start = $this->uniqueSlot();
$appointment = $this->newAppointment($doctor, $this->createUser(), $start, $start + 900, $clinic);
$this->em->persist($appointment);
$this->em->flush();
self::assertSame('clinic', $appointment->getEntityType());
self::assertSame($clinic->getId(), $appointment->getEntityId());
}
/** یک پزشک در دو محیط = دو مالک متفاوت برای دو نوبت. */
public function testSameDoctorGetsDifferentOwnersInDifferentEnvironments(): void
{
$doctor = $this->makeDoctor();
$clinic = $this->makeClinic();
$clinic->getDoctors()->add($doctor);
$this->em->flush();
$personal = $this->newAppointment($doctor, $this->createUser(), $this->uniqueSlot(), $this->uniqueSlot() + 900);
$inClinic = $this->newAppointment($doctor, $this->createUser(), $this->uniqueSlot(), $this->uniqueSlot() + 900, $clinic);
$this->em->persist($personal);
$this->em->persist($inClinic);
$this->em->flush();
self::assertSame(['doctor', $doctor->getId()], [$personal->getEntityType(), $personal->getEntityId()]);
self::assertSame(['clinic', $clinic->getId()], [$inClinic->getEntityType(), $inClinic->getEntityId()]);
}
/** ❌ محیط حل‌نشده هرگز ذخیره نمی‌شود — نه با شناسهٔ صفر، نه بی‌صدا. */
public function testAssigningAnUnresolvedContextIsRejected(): void
{
$doctor = $this->makeDoctor();
$start = $this->uniqueSlot();
$appointment = new Appointment($doctor, $this->createUser(), $start, $start + 900);
$this->expectException(\InvalidArgumentException::class);
$appointment->assignTenant(EntityContext::unknown());
}
/**
* ⚠️ حالت مرزی: دو پزشکِ متفاوت در یک کلینیک، هر کدام برنامهٔ هفتگی خودشان —
* کلید یکتا باید doctor_id را هم داشته باشد وگرنه پزشک دوم رد می‌شود.
*/
public function testTwoDoctorsInOneClinicEachKeepTheirOwnSchedule(): void
{
$clinic = $this->makeClinic();
$doctorA = $this->makeDoctor();
$doctorB = $this->makeDoctor();
$clinic->getDoctors()->add($doctorA);
$clinic->getDoctors()->add($doctorB);
$this->em->flush();
$this->em->persist($this->newWeeklySchedule($doctorA, [], $clinic));
$this->em->persist($this->newWeeklySchedule($doctorB, [], $clinic));
$this->em->flush();
$rows = $this->em->getRepository(WeeklySchedule::class)
->findBy(['entityType' => 'clinic', 'entityId' => $clinic->getId()]);
self::assertCount(2, $rows);
}
/** همان پزشک، همان محیط، دو برنامه — هنوز غیرممکن است. */
public function testOneSchedulePerEnvironmentPerDoctorIsStillEnforced(): void
{
$doctor = $this->makeDoctor();
$this->em->persist($this->newWeeklySchedule($doctor, []));
$this->em->flush();
$this->em->persist($this->newWeeklySchedule($doctor, []));
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
}
+2 -2
View File
@@ -30,7 +30,7 @@ class BookingWindowUnitTest extends ApiTestCase
]]];
}
$schedule = new WeeklySchedule($doctor, $days);
$schedule = $this->newWeeklySchedule($doctor, $days);
if ($meta !== []) {
$schedule->setMeta($meta);
}
@@ -94,7 +94,7 @@ class BookingWindowUnitTest extends ApiTestCase
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر واحد نامعتبر');
$schedule = new WeeklySchedule($doctor, []);
$schedule = $this->newWeeklySchedule($doctor, []);
$schedule->setMeta(['booking_window_unit' => 'day', 'booking_window_value' => 10]);
$schedule->setMeta(['booking_window_unit' => 'year']);
@@ -49,8 +49,7 @@ class ClinicAppointmentAccessTest extends ApiTestCase
$patient ??= $this->createUser();
$start = strtotime('+3 days 10:00');
$appointment = new Appointment($doctor, $patient, $start, $start + 900);
$appointment->setClinic($clinic);
$appointment = $this->newAppointment($doctor, $patient, $start, $start + 900, $clinic);
$this->em->persist($appointment);
$this->em->flush();
@@ -16,7 +16,7 @@ class DateOverrideOwnershipTest extends ApiTestCase
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor);
$override = new DateOverride($doctor, time(), true);
$override = $this->newDateOverride($doctor, time(), true);
$this->em->persist($override);
$this->em->flush();
@@ -29,7 +29,7 @@ class DoctorAppointmentFilterTest extends ApiTestCase
private function booking(Doctor $doctor, int $start, ?string $status = null, ?string $name = null): Appointment
{
$a = new Appointment($doctor, $this->createUser(), $start, $start + 1_800);
$a = $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
if ($name !== null) {
$a->setPatientName($name);
}
@@ -26,7 +26,7 @@ class OnlineBookingManagementTest extends ApiTestCase
$date = date('Y-m-d', strtotime('tomorrow'));
$dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7);
$schedule = new WeeklySchedule($doctor, [
$schedule = $this->newWeeklySchedule($doctor, [
$dayKey => ['sessions' => [[
'active' => true,
'start_time' => '10:00',
@@ -143,7 +143,7 @@ class OnlineBookingManagementTest extends ApiTestCase
$dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7);
// برنامهٔ نوبت‌دهی در محیط کلینیک با نوبت‌دهی آنلاینِ خاموش.
$schedule = new WeeklySchedule($doctor, [
$schedule = $this->newWeeklySchedule($doctor, [
$dayKey => ['sessions' => [[
'active' => true, 'start_time' => '10:00', 'end_time' => '12:00',
'duration_per_patient' => 30, 'location_id' => 1,
+1 -1
View File
@@ -18,7 +18,7 @@ class ScheduleOwnershipTest extends ApiTestCase
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر تست');
$this->em->persist($doctor);
$schedule = new WeeklySchedule($doctor, ['sat' => []]);
$schedule = $this->newWeeklySchedule($doctor, ['sat' => []]);
$this->em->persist($schedule);
$this->em->flush();
+3 -3
View File
@@ -24,7 +24,7 @@ class ServiceBasedSlotsTest extends ApiTestCase
$date = date('Y-m-d', strtotime('tomorrow'));
$dayKey = (string)(((int) date('w', strtotime($date)) + 1) % 7);
$schedule = new WeeklySchedule($doctor, [
$schedule = $this->newWeeklySchedule($doctor, [
$dayKey => ['sessions' => [[
'active' => true,
'start_time' => '15:00',
@@ -61,7 +61,7 @@ class ServiceBasedSlotsTest extends ApiTestCase
$patient = $this->createUser(['ROLE_USER']);
$start = strtotime($date . ' 15:00');
$appt = new Appointment($doctor, $patient, $start, $start + 30 * 60);
$appt = $this->newAppointment($doctor, $patient, $start, $start + 30 * 60);
$appt->transitionTo(Appointment::STATUS_CONFIRMED);
$this->em->persist($appt);
$this->em->flush();
@@ -87,7 +87,7 @@ class ServiceBasedSlotsTest extends ApiTestCase
{
$owner = $this->createUser(['ROLE_DOCTOR']);
$doctor = new Doctor($owner, 'دکتر متا');
$schedule = new WeeklySchedule($doctor, []);
$schedule = $this->newWeeklySchedule($doctor, []);
// پیش‌فرض = اسلاتی
$this->assertSame(WeeklySchedule::MODE_SLOT, $schedule->getMeta()['booking_mode']);
@@ -24,7 +24,7 @@ class ServiceModeSectionDurationTest extends ApiTestCase
$date = date('Y-m-d', strtotime('tomorrow'));
$dayKey = (string) (((int) date('w', strtotime($date)) + 1) % 7);
$schedule = new WeeklySchedule($doctor, [
$schedule = $this->newWeeklySchedule($doctor, [
$dayKey => ['sessions' => [[
'active' => true, 'start_time' => '15:00', 'end_time' => '19:00',
'duration_per_patient' => 20, 'location_id' => 1,
+1 -1
View File
@@ -27,7 +27,7 @@ class SlotUniquenessTest extends ApiTestCase
private function newBooking(Doctor $doctor, int $start): Appointment
{
return new Appointment($doctor, $this->createUser(), $start, $start + 1_800);
return $this->newAppointment($doctor, $this->createUser(), $start, $start + 1_800);
}
public function testTwoLiveBookingsOnSameSlotViolateUniqueKey(): void