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>
22 KiB
فاز ۲ — نشانهگذاری 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 §۴).