Files
clinicpro/.claude/prompt/tenant-02-mark-booking-tables.md
T
hamedandClaude Opus 5 1a7bf53577 refactor(tenant): make EntityContextResolver the single context resolver
Phase 1 of the tenant-marking series. The "which environment is this user
working in?" decision was reimplemented in six places, each reading
UserActiveContext.db_uuid and then guessing whether the uuid belongs to a
clinic or a doctor. Every copy was a place the roles could silently diverge.

EntityContextResolver already encoded the right precedence (explicit
clinic_uuid > stored active context > role) but only five files used it, and
it did not recognise secretaries at all: canActInClinic accepted admins,
clinic owners and member doctors, so a secretary's active clinic context
always collapsed to unknown. That gap is why SecretaryAccessChecker carried
its own copy of the logic.

- canActInClinic now also accepts an active DoctorSecretary relation, and a
  matching canActForDoctor covers the personal-practice branch.
- AppointmentAccessChecker, ClinicDoctorAccessChecker, SecretaryAccessChecker,
  PatientRecordScopeResolver, MyAppointmentsController and the secretary
  dashboard all resolve through it now.
- PatientRecordScopeResolver keeps only its real responsibility: which
  doctors' patients are visible inside the resolved environment.
- The resolver answers "where"; ClinicDoctorPermissionChecker and
  SecretaryPermissionChecker still answer "what may you do".

Left deliberately untouched, with the reason recorded at each site:
SubscriptionController, InventoryController and TenantTagController check
ROLE_DOCTOR unconditionally and ignore the active context, so a member doctor
sees personal inventory/tags/subscription even inside a clinic. Switching them
changes what users see, which is a product decision, not a refactor.
AuthController keeps its repository because it writes the active context.

tests/ApiTestCase now seeds the "free" subscription plan. db_test had no such
row, so getEffectivePlan returned null, every hasFeature() was false and 83
tests across Patient, ClinicService, Insurance and Appointment failed with 403.

No schema, route, request, response or error code changed.

Tests: 813 passing (was 730 passing / 83 failing). PHPStan clean on all
changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 10:59:22 +03:30

22 KiB
Raw Blame History

فاز ۲ — نشانه‌گذاری tenant روی جدول‌های نوبت‌دهی

پرامپت دوم از سری پنج‌تایی. پیش‌نیاز: tenant-01-entity-context-unify.md باید کامل و سبز شده باشد. ۱. tenant-01-entity-context-unify.md۲. نشانه‌گذاری جدول‌های نوبت (همین فایل) → ۳. tenant-03-unify-owner-columns.md → ۴. tenant-04-enforce-tenant-filter.md۵. tenant-05-audit-and-docs.md

زمینه

پس از فاز ۱، تشخیص محیط یک نقطه دارد و EntityContext::toEntityPair() جفت (entity_type, entity_id) را برمی‌گرداند. اما جدول‌های هستهٔ نوبت‌دهی هنوز این جفت را ذخیره نمی‌کنند — آن‌ها با doctor_id اجباری و clinic_id تهی‌پذیر کار می‌کنند:

// src/Appointment/Entity/Appointment.php
#[ORM\ManyToOne(targetEntity: Doctor::class)]
private Doctor $doctor;                       // اجباری

#[ORM\ManyToOne(targetEntity: \App\Clinic\Entity\Clinic::class)]
private ?\App\Clinic\Entity\Clinic $clinic = null;   // NULL = مطب شخصی

نتیجه: هر کوئری‌ای که می‌خواهد «دادهٔ این محیط» را بگیرد، باید خودش clinic_id IS NULL ? doctor : clinic را بازسازی کند. همین بازسازی است که در فاز ۱ به‌عنوان تکرار منطق حذف شد — ولی در لایهٔ کوئری هنوز باقی است و فاز ۴ (فیلتر خودکار Doctrine) بدون ستون واقعی روی این جدول‌ها اصلاً کار نمی‌کند.

مشکل / هدف

مشکل: appointments، weekly_schedules و date_overrides محیط خود را به‌صورت ضمنی و با دو ستون متفاوت نگه می‌دارند؛ weekly_schedules و date_overrides برای رفع مشکل یکتاییِ NULL مجبور به یک ستون تولیدشدهٔ مخصوص MariaDB شده‌اند:

// src/Appointment/Entity/WeeklySchedule.php و DateOverride.php
#[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;

هدف: جفت (entity_type, entity_id) روی این سه جدول ذخیره شود، ایندکس‌ها با tenant به‌عنوان ستون پیشرو بازسازی شوند، و هک clinic_key حذف شود.

⚠️ دو چیزی که عمداً تغییر نمی‌کنند

این دو مورد در کد فعلی دلیل مکتوب دارند و نباید به بهانهٔ یکسان‌سازی خراب شوند:

۱. کلید یکتایی اسلات، سطح پزشک می‌ماند — نه سطح tenant.

// src/Appointment/Entity/Appointment.php
/**
 * کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط
 * جداست (WeeklySchedule با UNIQUE(doctor_id, clinic_key)) و می‌تواند با محیط
 * دیگر هم‌پوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی
 * اجازهٔ رزرو هم‌زمان همان پزشک در مطب و کلینیک — نه رفع باگ.
 */
private function refreshActiveSlotKey(): void
{
    $this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
        ? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
        : null;
}

افزودن tenant به active_slot_key یک باگ امنیتی-تجاری می‌سازد: همان پزشک هم‌زمان در مطب شخصی و کلینیک رزرو می‌شود. دست نزن.

۲. جدول holidays نشانه‌گذاری tenant نمی‌شود.

// src/Appointment/Entity/Holiday.php
/**
 * تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیش‌فرضاً سراسری است:
 * «پزشک آن روز نیست» یک واقعیت فیزیکی است و هم‌زمان روی مطب شخصی و همهٔ کلینیک‌ها
 * اثر می‌گذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست.
 */

اینجا clinic = NULL معنایش «مطب شخصی» نیست، معنایش «همهٔ محیط‌ها» است. جفت (entity_type, entity_id) نمی‌تواند این را بیان کند و تبدیلش، تعطیلی‌های سراسری را به تعطیلی مطب شخصی تنزل می‌دهد. holidays با همان clinic_id تهی‌پذیر می‌ماند و در فاز ۴ در whitelist «جدول‌های خارج از فیلتر tenant» قرار می‌گیرد.

معیار پذیرش

  • موفق: بعد از migration، برای هر ردیف appointments رابطهٔ زیر برقرار است — قابل تأیید با کوئری در «نحوه تست» وظیفهٔ ۲: clinic_id IS NULL → (entity_type='doctor', entity_id=doctor_id) و clinic_id IS NOT NULL → (entity_type='clinic', entity_id=clinic_id). ثبت یک نوبت جدید از پنل (هم در محیط کلینیک، هم در محیط مطب شخصی) جفت را خودکار و درست پر می‌کند.
  • خطا: تلاش برای ذخیرهٔ Appointment بدون محیطِ حل‌شده (EntityContext::unknown()) → AppException با کد از ErrorCodes، نه SQLSTATE خام و نه ردیف با entity_id = 0.
  • ⚠️ مرزی: دو پزشک متفاوت در یک کلینیک هر کدام WeeklySchedule خودشان را دارند → هر دو با (entity_type='clinic', entity_id=<همان clinic>) ذخیره می‌شوند و یکتایی نباید نقض شود (به همین دلیل کلید یکتا باید doctor_id را هم داشته باشد — وظیفهٔ ۳). همچنین: نوبتی که clinic_id دارد ولی آن کلینیک بعداً حذف شده → migration نباید بشکند (وظیفهٔ ۲، بند «ردیف‌های یتیم»).

فایل‌های مرتبط

فایل نقش
src/Appointment/Entity/Appointment.php افزودن جفت tenant + بازسازی ایندکس‌ها
src/Appointment/Entity/WeeklySchedule.php افزودن جفت + حذف clinic_key + بازسازی یکتایی
src/Appointment/Entity/DateOverride.php همان کار WeeklySchedule
src/Appointment/Entity/Holiday.php بدون تغییر — فقط افزودن یادداشت استثنا در docblock
src/Shared/Context/EntityContext.php منبع جفت (toEntityPair())
src/Appointment/Repository/AppointmentRepository.php کوئری‌هایی که با clinic_id IS NULL محیط را بازسازی می‌کنند
src/Appointment/Repository/WeeklyScheduleRepository.php کوئری‌های مبتنی بر clinic_key
src/Appointment/Repository/DateOverrideRepository.php همان
migrations/ migration جدید (schema + backfill)
tests/Appointment/SlotUniquenessTest.php تست‌های موجود که نباید بشکنند

وضعیت فعلی

ایندکس‌های فعلی appointments — هیچ‌کدام tenant را نمی‌شناسند:

#[ORM\Entity(repositoryClass: AppointmentRepository::class)]
#[ORM\Table(name: 'appointments')]
#[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
{
    #[ORM\Column(name: 'active_slot_key', type: 'string', length: 64, nullable: true, unique: true)]
    private ?string $activeSlotKey = null;

یکتایی فعلی weekly_schedules و date_overrides — وابسته به ستون تولیدشده:

#[ORM\Table(name: 'weekly_schedules')]
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_key'])]

#[ORM\Table(name: 'date_overrides')]
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_clinic_date', columns: ['doctor_id', 'clinic_key', 'date'])]

وظایف

۱. ساخت trait مشترک برای جفت tenant

سه entity همان دو ستون را می‌گیرند. به‌جای کپی، یک trait در src/Shared/Tenant/ بساز.

// src/Shared/Tenant/TenantOwnedTrait.php
namespace App\Shared\Tenant;

use App\Shared\Context\EntityContext;
use Doctrine\ORM\Mapping as ORM;

/**
 * جفت مالکیتِ محیط — همان قراردادی که ServiceSection و PatientRecord از قبل دارند.
 * مقدارها فقط از EntityContext::toEntityPair() می‌آیند تا با فاز ۱ یک منبع بمانند.
 */
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; }

    public function assignTenant(EntityContext $context): void
    {
        if (!$context->isResolved()) {
            throw new \InvalidArgumentException('Cannot assign an unresolved tenant context.');
        }

        [$this->entityType, $this->entityId] = $context->toEntityPair();
    }
}

طول length: 10 عمدی است — با service_sections و clinic_staff یکی است تا کوئری‌های JOIN بین جدول‌ها collation mismatch ندهند. (tenant_tags طول ۲۰ دارد؛ آن ناهماهنگی در فاز ۳ رسیدگی می‌شود، اینجا نه.)

نحوه تست: ddev exec php vendor/bin/phpstan analyse src/Shared/Tenant بدون خطا؛ و تست واحد که assignTenant(EntityContext::unknown()) استثنا پرتاب کند.


۲. افزودن جفت به Appointment + backfill

trait را به entity اضافه کن و در سازنده از EntityContext پرش کن. doctor و clinic حذف نمی‌شوند — جفت جدید یک denormalization عمدی است، نه جایگزین: doctor_id هنوز برای یکتایی اسلات و تقویم لازم است.

migration دو مرحله‌ای بنویس (schema سپس backfill):

-- گام ۱: ستون‌ها تهی‌پذیر اضافه شوند
ALTER TABLE appointments
  ADD entity_type VARCHAR(10) NULL,
  ADD entity_id   INT NULL;

-- گام ۲: backfill — کلینیک اولویت دارد، در نبودش مطب شخصی
UPDATE appointments
SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'),
    entity_id   = IFNULL(clinic_id, doctor_id);

-- گام ۳: ردیف‌های یتیم (clinic_id به کلینیک حذف‌شده اشاره می‌کند)
UPDATE appointments a
  LEFT JOIN clinics c ON c.id = a.clinic_id
SET a.entity_type = 'doctor', a.entity_id = a.doctor_id
WHERE a.clinic_id IS NOT NULL AND c.id IS NULL;

-- گام ۴: تأیید صفر بودن باقی‌مانده — اگر صفر نبود migration باید fail کند
-- SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL OR entity_id IS NULL;

-- گام ۵: تازه حالا NOT NULL
ALTER TABLE appointments
  MODIFY entity_type VARCHAR(10) NOT NULL,
  MODIFY entity_id   INT NOT NULL;

در متد up() migration، بین گام ۴ و ۵ یک abortIf() بگذار تا اگر ردیف پر نشده‌ای ماند، migration به‌جای ساختن دادهٔ خراب متوقف شود:

$remaining = (int) $this->connection->fetchOne(
    'SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL OR entity_id IS NULL'
);
$this->abortIf($remaining > 0, "Backfill left {$remaining} appointments without a tenant.");

نحوه تست:

ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
# صحت backfill — هر سه کوئری باید 0 برگردانند
ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL"
ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments WHERE clinic_id IS NULL AND (entity_type<>'doctor' OR entity_id<>doctor_id)"
ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments a JOIN clinics c ON c.id=a.clinic_id WHERE a.entity_type<>'clinic' OR a.entity_id<>a.clinic_id"

۳. WeeklySchedule و DateOverride — افزودن جفت و حذف clinic_key

اینجا جفت tenant جایگزین clinic_key می‌شود، ولی doctor_id در کلید یکتا می‌ماند.

چرا doctor_id باید در کلید بماند: در یک کلینیک چند پزشک وجود دارد و هر کدام برنامهٔ هفتگی خودش را دارد. اگر کلید فقط (entity_type, entity_id) باشد، پزشک دوم همان کلینیک با نقض یکتایی مواجه می‌شود. این همان حالت مرزی معیار پذیرش است.

#[ORM\Table(name: 'weekly_schedules')]
#[ORM\UniqueConstraint(name: 'uniq_weekly_schedule_doctor_tenant',
    columns: ['doctor_id', 'entity_type', 'entity_id'])]

#[ORM\Table(name: 'date_overrides')]
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_tenant_date',
    columns: ['doctor_id', 'entity_type', 'entity_id', 'date'])]

migration:

ALTER TABLE weekly_schedules ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL;
UPDATE weekly_schedules
SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'),
    entity_id   = IFNULL(clinic_id, doctor_id);
-- abortIf روی باقی‌ماندهٔ NULL
ALTER TABLE weekly_schedules
  MODIFY entity_type VARCHAR(10) NOT NULL,
  MODIFY entity_id   INT NOT NULL;

DROP INDEX idx_weekly_schedules_doctor_clinic ON weekly_schedules;
CREATE UNIQUE INDEX uniq_weekly_schedule_doctor_tenant
  ON weekly_schedules (doctor_id, entity_type, entity_id);
ALTER TABLE weekly_schedules DROP COLUMN clinic_key;

ترتیب حیاتی: clinic_key فقط بعد از ساخته شدن ایندکس جدید حذف شود، وگرنه بین دو دستور، جدول بدون هیچ محافظ یکتایی می‌ماند. همین ترتیب برای date_overrides.

clinic_id را نگه دار — چند نقطهٔ کد (از جمله AssignScheduleClinicCommand و کوئری‌های SlotCalculatorService) هنوز مستقیم به آن وابسته‌اند و حذفش دامنهٔ این فاز را می‌ترکاند. حذفش کار فاز ۵ است، بعد از اینکه فاز ۴ ثابت کرد جفت tenant همه‌جا کافی است.

نحوه تست:

ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ddev exec php bin/phpunit tests/Appointment/
# حالت مرزی: دو پزشک در یک کلینیک
ddev exec php bin/console dbal:run-sql "SELECT entity_type, entity_id, COUNT(DISTINCT doctor_id) d FROM weekly_schedules GROUP BY 1,2 HAVING d > 1"
# ↑ باید حداقل یک ردیف برگرداند (یعنی چند پزشک در یک کلینیک) و migration نشکسته باشد
ddev exec php bin/console dbal:run-sql "SHOW COLUMNS FROM weekly_schedules LIKE 'clinic_key'"   # خالی

۴. بازسازی ایندکس‌های appointments با tenant پیشرو

هر ایندکسی که برای کوئری‌های «لیست نوبت‌های این محیط» استفاده می‌شود باید tenant را به‌عنوان ستون اول داشته باشد، وگرنه MariaDB نمی‌تواند از آن برای فیلتر tenant استفاده کند.

#[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')]

idx_appointments_doctor_slot را حذف نکنAppointmentRepository::isSlotTaken() و SlotCalculatorService روی آن کوئری می‌زنند و آن کوئری‌ها ذاتاً سطح پزشک‌اند.

بعد از migration، پلن کوئری لیست نوبت‌ها را بررسی کن که واقعاً ایندکس جدید را انتخاب می‌کند.

نحوه تست:

ddev exec php bin/console dbal:run-sql "EXPLAIN SELECT * FROM appointments WHERE entity_type='clinic' AND entity_id=1 AND slot_start > UNIX_TIMESTAMP() ORDER BY slot_start LIMIT 20"
# ستون key باید idx_appointments_tenant_slot باشد، نه NULL و نه ALL

۵. اتصال نقطهٔ نوشتن به EntityContext

هر جایی که Appointment، WeeklySchedule یا DateOverride ساخته می‌شود باید assignTenant() را با EntityContext صدا بزند. نقاط ساخت را با این دستور پیدا کن:

ddev exec grep -rn "new Appointment(\|new WeeklySchedule(\|new DateOverride(" src --include="*.php"

برای مسیر رزرو عمومی (بیمار، نه کاربر پنل)، محیط از BookingContextResolver می‌آید که ?Clinic برمی‌گرداند؛ تبدیلش:

$context = $clinic !== null
    ? EntityContext::forClinic($clinic)
    : EntityContext::forDoctor($doctor);

$appointment->assignTenant($context);

هیچ مقدار پیش‌فرضی نگذار. اگر محیط حل نشد، AppException با کد از ErrorCodes پرتاب شود — نه entity_id = 0 و نه حدس زدن. این همان معیار پذیرش است.

نحوه تست: ثبت نوبت واقعی از پنل با کاربر تست (clinicpro-qa-accounts) در هر دو محیط، سپس:

ddev exec php bin/console dbal:run-sql "SELECT id, doctor_id, clinic_id, entity_type, entity_id FROM appointments ORDER BY id DESC LIMIT 5"

جفت باید با محیطی که نوبت در آن ثبت شده بخواند.


۶. یادداشت استثنا روی Holiday

holidays نشانه‌گذاری نمی‌شود. برای اینکه فاز ۴ (تست ساختاری روی همهٔ entityها) این را استثنای عمدی بشناسد نه فراموشی، در docblock کلاس یادداشت بگذار:

/**
 * ...docblock موجود...
 *
 * عمداً جفت (entity_type, entity_id) ندارد: در این جدول clinic = NULL یعنی
 * «همهٔ محیط‌ها»، نه «مطب شخصی». جفت tenant نمی‌تواند «همه» را بیان کند و
 * تبدیلش، تعطیلی سراسری را به تعطیلی مطب شخصی تنزل می‌دهد.
 * در whitelist فاز ۴ (TenantFilter) ثبت شده است.
 */

نحوه تست: بازبینی دستی؛ فاز ۴ این یادداشت را به whitelist تبدیل می‌کند.

نکات مهم

  • الگو: denormalization عمدی + trait مشترک. جفت tenant روی appointments تکرار اطلاعاتی است که از clinic_id/doctor_id قابل استخراج بود. دلیل پذیرش تکرار: فیلتر خودکار فاز ۴ و ایندکس tenant-پیشرو، هر دو به یک ستون واقعی نیاز دارند و با شرط IF(clinic_id IS NULL, ...) قابل ساخت نیستند. trait به‌جای کلاس پایه انتخاب شده چون این سه entity هیچ رفتار مشترک دیگری ندارند و ارث‌بری Doctrine هزینهٔ نقشه‌برداری اضافه می‌کند.
  • active_slot_key را دست نزن (بخش «دو چیزی که عمداً تغییر نمی‌کنند»). اگر تستی از تو خواست آن را عوض کنی، تست غلط است نه کد.
  • clinic_id و doctor_id حذف نمی‌شوند. این فاز فقط اضافه می‌کند. حذف ستون‌های قدیمی بعد از اثبات کفایت جفت tenant در فاز ۵ بررسی می‌شود.
  • ترتیب migration: ستون تهی‌پذیر → backfill → abortIf روی باقی‌ماندهٔ NULL → NOT NULL → ایندکس جدید → حذف ایندکس/ستون قدیمی. هر جابه‌جایی در این ترتیب یعنی پنجره‌ای که جدول بدون محافظ یکتایی می‌ماند.
  • قبل از migrate روی دادهٔ واقعی، بکاپ بگیر: ddev export-db --file=/tmp/pre-tenant-phase2.sql.gz. این migration دادهٔ موجود را می‌نویسد و rollback خودکار ندارد.
  • کلاینت‌های متأثر: entity_type/entity_id نباید در پاسخ API ظاهر شوند مگر جایی که از قبل ظاهر می‌شدند. اگر toArray() نوبت را عوض کردی، nobat724_front/services/response.js و clinic-pro-tauri/src/service/response.js باید دستی بررسی شوند — build آن‌ها خطا نمی‌دهد.
  • مستندات: اگر شکل پاسخ نوبت تغییر کرد، docs/api/appointment.md همان جلسه به‌روز شود با JSON واقعیِ خروجی اجرا، نه دست‌ساز (guidelines §۴).