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:
@@ -1018,6 +1018,7 @@ class AdminApiController extends BaseController
|
||||
}
|
||||
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
|
||||
$appointment->setClinic($bookingClinic);
|
||||
$appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $bookingClinic));
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true);
|
||||
if ($locationId !== null) $appointment->setAddressId($locationId);
|
||||
|
||||
|
||||
@@ -519,6 +519,7 @@ class AppointmentController extends BaseController
|
||||
|
||||
// آدرس نوبت از روی session متناظر در برنامهی هفتگی تعیین میشود (location_id).
|
||||
$appointment->setClinic($bookingClinic);
|
||||
$appointment->assignTenant(\App\Shared\Context\EntityContext::forBooking($doctor, $bookingClinic));
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic);
|
||||
if ($locationId !== null) {
|
||||
$appointment->setAddressId($locationId);
|
||||
|
||||
@@ -152,6 +152,7 @@ class AppointmentSettingsController extends BaseController
|
||||
$schedule->setSetting($data['schedule'] ?? []);
|
||||
} else {
|
||||
$schedule = new WeeklySchedule($doctor, $data['schedule'] ?? [], $clinic);
|
||||
$schedule->assignTenant(EntityContext::forBooking($doctor, $clinic));
|
||||
}
|
||||
|
||||
if (isset($data['meta']) && is_array($data['meta'])) {
|
||||
@@ -310,6 +311,7 @@ class AppointmentSettingsController extends BaseController
|
||||
}
|
||||
|
||||
$override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false), $clinic);
|
||||
$override->assignTenant(EntityContext::forBooking($doctor, $clinic));
|
||||
if (isset($data['reason'])) $override->setReason($data['reason']);
|
||||
if (isset($data['custom_slots'])) $override->setSetting($data['custom_slots']);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Appointment\Controller;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Shared\Context\EntityContext;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Repository\SlotTakenException;
|
||||
@@ -182,6 +183,7 @@ class MyAppointmentsController extends BaseController
|
||||
// یعنی مطب شخصی، نه «هر برنامهای که پیدا شد».
|
||||
$bookingClinic = $this->bookingContext->resolve($doctor, $data['clinic_uuid'] ?? null);
|
||||
$appointment->setClinic($bookingClinic);
|
||||
$appointment->assignTenant(EntityContext::forBooking($doctor, $bookingClinic));
|
||||
$locationId = $this->slotCalculator->resolveSlotLocationId($doctor, $slotStart, $bookingClinic, true);
|
||||
if ($locationId !== null) $appointment->setAddressId($locationId);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Appointment\Entity;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Insurance\Enum\ServiceCategory;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -13,11 +14,22 @@ use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AppointmentRepository::class)]
|
||||
#[ORM\Table(name: 'appointments')]
|
||||
// tenant پیشرو — لیستهای پنل همیشه محیطمحورند
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'slot_start'], name: 'idx_appointments_tenant_slot')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_appointments_tenant_status')]
|
||||
// بدون tenant — عمدی: یکتاییِ اسلات و تقویم سطح پزشکاند، نه محیط
|
||||
#[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')]
|
||||
#[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')]
|
||||
#[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')]
|
||||
class Appointment
|
||||
{
|
||||
/**
|
||||
* محیطِ مالکِ نوبت. denormalization عمدی روی clinic/doctor موجود: فیلترِ خودکارِ
|
||||
* tenant و ایندکسِ tenant-پیشرو هر دو به ستون واقعی نیاز دارند و با شرطِ
|
||||
* IF(clinic_id IS NULL, …) ساخته نمیشوند.
|
||||
*/
|
||||
use TenantOwnedTrait;
|
||||
|
||||
// Status machine: pending → confirmed → completed
|
||||
// ↘ cancelled_by_doctor / cancelled_by_user
|
||||
// pending → expired (cron)
|
||||
@@ -214,7 +226,7 @@ class Appointment
|
||||
* while the appointment occupies the slot; null once it is cancelled.
|
||||
*
|
||||
* کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط
|
||||
* جداست (WeeklySchedule با UNIQUE(doctor_id, clinic_key)) و میتواند با محیط
|
||||
* جداست (WeeklySchedule با UNIQUE(doctor_id, entity_type, entity_id)) و میتواند با محیط
|
||||
* دیگر همپوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی
|
||||
* اجازهٔ رزرو همزمان همان پزشک در مطب و کلینیک — نه رفع باگ.
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Appointment\Entity;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Context\EntityContext;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Appointment\Repository\DateOverrideRepository;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -14,9 +16,12 @@ use Symfony\Component\Uid\Uuid;
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: DateOverrideRepository::class)]
|
||||
#[ORM\Table(name: 'date_overrides')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_clinic_date', columns: ['doctor_id', 'clinic_key', 'date'])]
|
||||
// doctor_id در کلید میماند: چند پزشکِ یک کلینیک هر کدام استثنای روزِ خودشان را دارند.
|
||||
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_tenant_date', columns: ['doctor_id', 'entity_type', 'entity_id', 'date'])]
|
||||
class DateOverride
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
@@ -34,10 +39,6 @@ class DateOverride
|
||||
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Clinic $clinic = null;
|
||||
|
||||
/** ستون تولیدشده: IFNULL(clinic_id, 0) — تا یکتایی با clinic_id تهی هم برقرار بماند. */
|
||||
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
|
||||
private int $clinicKey = 0;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $date;
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ use Symfony\Component\Uid\Uuid;
|
||||
* تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیشفرضاً سراسری است:
|
||||
* «پزشک آن روز نیست» یک واقعیت فیزیکی است و همزمان روی مطب شخصی و همهٔ کلینیکها
|
||||
* اثر میگذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست.
|
||||
*
|
||||
* عمداً جفت (entity_type, entity_id) ندارد: در این جدول clinic = NULL یعنی «همهٔ
|
||||
* محیطها»، نه «مطب شخصی». جفت tenant نمیتواند «همه» را بیان کند و تبدیلش، تعطیلی
|
||||
* سراسری را به تعطیلی مطب شخصی تنزل میدهد. باید در whitelist فیلتر tenant بماند.
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: HolidayRepository::class)]
|
||||
#[ORM\Table(name: 'holidays')]
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Appointment\Entity;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Shared\Context\EntityContext;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use App\Appointment\Repository\WeeklyScheduleRepository;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
@@ -17,9 +19,13 @@ use Symfony\Component\Uid\Uuid;
|
||||
*/
|
||||
#[ORM\Entity(repositoryClass: WeeklyScheduleRepository::class)]
|
||||
#[ORM\Table(name: 'weekly_schedules')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_key'])]
|
||||
// doctor_id در کلید میماند: در یک کلینیک چند پزشک هستند و هر کدام برنامهٔ خودش را
|
||||
// دارد، پس (entity_type, entity_id) بهتنهایی برای پزشک دوم نقض یکتایی میسازد.
|
||||
#[ORM\UniqueConstraint(name: 'uniq_weekly_schedule_doctor_tenant', columns: ['doctor_id', 'entity_type', 'entity_id'])]
|
||||
class WeeklySchedule
|
||||
{
|
||||
use TenantOwnedTrait;
|
||||
|
||||
public const DAYS = ['saturday', 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
|
||||
|
||||
public const META_KEY = 'meta';
|
||||
@@ -55,16 +61,6 @@ class WeeklySchedule
|
||||
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Clinic $clinic = null;
|
||||
|
||||
/**
|
||||
* ستون تولیدشدهٔ پایگاهداده: IFNULL(clinic_id, 0).
|
||||
*
|
||||
* MySQL/MariaDB مقادیر NULL را در unique index متمایز میشمارند، پس
|
||||
* UNIQUE(doctor_id, clinic_id) جلوی دو برنامهٔ شخصی برای یک پزشک را نمیگرفت.
|
||||
* این ستون NULL را به 0 نگاشت میکند تا یکتایی در سطح دیتابیس تضمین شود.
|
||||
*/
|
||||
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
|
||||
private int $clinicKey = 0;
|
||||
|
||||
#[ORM\Column(type: 'json')]
|
||||
private array $setting = [];
|
||||
|
||||
@@ -94,6 +90,7 @@ class WeeklySchedule
|
||||
{
|
||||
$this->clinic = $clinic;
|
||||
$this->updatedAt = time();
|
||||
$this->assignTenant(EntityContext::forBooking($this->doctor, $clinic));
|
||||
return $this;
|
||||
}
|
||||
public function getSetting(): array { return $this->setting; }
|
||||
|
||||
@@ -35,6 +35,15 @@ final class EntityContext
|
||||
return new self(self::TYPE_CLINIC, $clinic->getId(), $clinic);
|
||||
}
|
||||
|
||||
/**
|
||||
* محیط یک رزرو: کلینیکِ دادهشده، وگرنه مطب شخصی همان پزشک — همان قراردادی که
|
||||
* {@see \App\Appointment\Service\BookingContextResolver} با ?Clinic بیان میکند.
|
||||
*/
|
||||
public static function forBooking(Doctor $doctor, ?Clinic $clinic): self
|
||||
{
|
||||
return $clinic !== null ? self::forClinic($clinic) : self::forDoctor($doctor);
|
||||
}
|
||||
|
||||
public static function unknown(): self
|
||||
{
|
||||
return new self(self::TYPE_UNKNOWN, null);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Tenant;
|
||||
|
||||
use App\Shared\Context\EntityContext;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* جفت مالکیتِ محیط — همان قراردادی که ServiceSection و PatientRecord از قبل دارند:
|
||||
* entity_type ∈ {doctor, clinic} بههمراه شناسهٔ همان موجودیت.
|
||||
*
|
||||
* مقدارها فقط از EntityContext::toEntityPair() میآیند تا «کدام محیط» یک منبع
|
||||
* حقیقت بیشتر نداشته باشد ({@see \App\Shared\Context\EntityContextResolver}).
|
||||
*
|
||||
* هیچکدام از دو ستون مقدار پیشفرض ندارند: اگر سازنده assignTenant() را فراموش
|
||||
* کند، flush با خطای «typed property must not be accessed before initialization»
|
||||
* میشکند. این عمدی است — ردیفِ بیمحیط بیصدا از دید همه پنهان میشود.
|
||||
*
|
||||
* طول ۱۰ با service_sections، patient_records و clinic_staff یکی است تا JOIN بین
|
||||
* جدولها به collation mismatch نخورد.
|
||||
*/
|
||||
trait TenantOwnedTrait
|
||||
{
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
|
||||
/** @throws \InvalidArgumentException اگر محیط حل نشده باشد */
|
||||
public function assignTenant(EntityContext $context): void
|
||||
{
|
||||
if (!$context->isResolved()) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Cannot assign an unresolved tenant context to %s.',
|
||||
static::class,
|
||||
));
|
||||
}
|
||||
|
||||
[$this->entityType, $this->entityId] = $context->toEntityPair();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user