fix(db): purge insurance config when a doctor/clinic is deleted (H5)

tenant_insurances, entity_insurance_pricing and tenant_service_coverages
reference their owner through a polymorphic (entity_type, entity_id) pair, so no
database FK can cascade their cleanup. Hard-deleting a doctor (DoctorController)
or clinic (AdminApiController) left these rows orphaned.

Add TenantInsuranceCleanupService::purgeForEntity() and call it from both delete
paths — removes coverage (via owning tenant_insurance ids), then tenant
insurances, then pricing.

Residual (separate, lower-freq paths): deleting an insurance category or a
service_item still orphans rows that reference them by id — tracked under the
medium-tier soft-ref findings.

Regression: tests/Insurance/TenantInsuranceCleanupTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 19:16:10 +03:30
co-authored by Claude Opus 4.8
parent d14ac38da5
commit 4cf6873900
7 changed files with 105 additions and 1 deletions
+2
View File
@@ -458,6 +458,8 @@ Delete a clinic.
**Permission:** `ROLE_ADMIN`
> **Side effect:** the clinic's insurance configuration (`tenant_insurances`, `entity_insurance_pricing`, and their `tenant_service_coverages`) is purged in the same request — polymorphic `entity_id`, cleaned up at the application level.
### Response `200`
```json
{ "success": true, "data": { "message": "کلینیک حذف شد" } }
+2
View File
@@ -261,6 +261,8 @@ Delete a doctor profile.
**Permission:** `ROLE_ADMIN`
> **Side effect:** the doctor's insurance configuration (`tenant_insurances`, `entity_insurance_pricing`, and their `tenant_service_coverages`) is purged in the same request — these reference the doctor via a polymorphic `entity_id` with no DB FK, so the cleanup is enforced in the application.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
+1 -1
View File
@@ -41,7 +41,7 @@ _None outstanding._
| ✅H2 | No UNIQUE `(doctor_id, slot_start)` on Appointment → double-booking race (index is non-unique) | src/Appointment/Entity/Appointment.php | db-unique | **DONE** — nullable unique `active_slot_key` (occupying = pending/confirmed, mirrors `isSlotTaken`); `bookAtomically` catches the unique violation + expires lapsed pendings in-txn; all 3 booking paths (online/my/admin) routed through it. Migration backfills one row per slot (non-destructive). `tests/Appointment/SlotUniquenessTest`. **NB:** backfill surfaced a real pre-existing double-booked slot in dev data (doctor 1764, two `expired` rows) — harmless (both expired = key NULL). |
| ✅H3 | `Payment.referenceId` not unique → same gateway callback credited twice | src/Payment/Entity/Payment.php:61-62 | db-unique | **DONE** — unique index on `reference_id` (NULL until success → no collision) + callback pre-check rejects replays. `tests/Payment/PaymentCallbackAmountTest::testReplayedGatewayReferenceIsRejected` |
| ✅H4 | `FinancialBreakdown.payment` onDelete CASCADE on non-nullable FK → deleting a Payment destroys ledger rows; should be RESTRICT | src/Settlement/Entity/FinancialBreakdown.php:28-30 | db-ondelete | **DONE** — onDelete RESTRICT + migration. `tests/Settlement/FinancialBreakdownIntegrityTest` |
| H5 | Insurance pricing/coverage modeled as raw int FKs (no FK/onDelete) → orphan rows on delete: `EntityInsurancePricing.entity_id/insurance_id`, `TenantInsurance.entity_id/insurance_id`, `TenantServiceCoverage.tenant_insurance_id` | src/Insurance/Entity/EntityInsurancePricing.php:25-29 · TenantInsurance.php:29,32 · TenantServiceCoverage.php:23-24 | db-ondelete | Delete insurance/tenant → children removed or restricted, no dangling rows |
| H5 | Insurance pricing/coverage modeled as raw int FKs (no FK/onDelete) → orphan rows on delete | src/Insurance/Entity/EntityInsurancePricing.php · TenantInsurance.php · TenantServiceCoverage.php | db-ondelete | **DONE (entity-owner path)**`entity_id` is polymorphic (doctor\|clinic) so no DB FK is possible; added `TenantInsuranceCleanupService::purgeForEntity()` wired into doctor + clinic DELETE (purges tenant_insurances + pricing + coverage). `tests/Insurance/TenantInsuranceCleanupTest`. **Residual (→ M20-adjacent):** orphans when an *insurance category* itself is deleted (`insurance_id` ref) or a *service_item* is deleted (`service_item_id` ref) — different deletion paths, lower freq. |
| H6 | N+1: clinic doctors list lazy-loads `specialties` per doctor | src/Clinic/Controller/ClinicController.php:326 · src/Doctor/Repository/DoctorRepository.php:49 | perf-nplus1 | SQL profiler on clinic doctors list → 1 query/doctor for specialties |
| H7 | N+1: comments list lazy-loads `likes``user`, `replies` (recursive), author realName | src/Rating/Controller/RatingController.php:278,352 | perf-nplus1 | Profiler GET comments → query count scales w/ comments |
| H8 | N+1: insurance service-coverage `serviceItemRepo->find()` per row in array_map | src/Insurance/Controller/InsuranceController.php:410 | perf-nplus1 | GET service-coverage → 1 find()/row |
@@ -34,6 +34,7 @@ class AdminApiController extends BaseController
public function __construct(
private readonly EntityManagerInterface $em,
private readonly \App\Appointment\Service\SlotCalculatorService $slotCalculator,
private readonly \App\Insurance\Service\TenantInsuranceCleanupService $insuranceCleanup,
) {}
// ── Users ─────────────────────────────────────────────────────────────────
@@ -553,6 +554,7 @@ class AdminApiController extends BaseController
$clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]);
if (!$clinic) return $this->error('CLINIC_NOT_FOUND', 'کلینیک یافت نشد', 404);
$this->insuranceCleanup->purgeForEntity(\App\Insurance\Entity\TenantInsurance::TYPE_CLINIC, $clinic->getId());
$this->em->remove($clinic);
$this->em->flush();
@@ -4,6 +4,8 @@ namespace App\Doctor\Controller;
use App\Appointment\Repository\WeeklyScheduleRepository;
use App\Auth\Entity\User;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Service\TenantInsuranceCleanupService;
use App\Auth\Repository\UserRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
@@ -39,6 +41,7 @@ class DoctorController extends BaseController
private readonly UserRepository $userRepo,
private readonly FileValidatorService $fileValidator,
private readonly WeeklyScheduleRepository $scheduleRepo,
private readonly TenantInsuranceCleanupService $insuranceCleanup,
private readonly string $projectDir,
) {}
@@ -369,6 +372,7 @@ class DoctorController extends BaseController
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
}
$this->insuranceCleanup->purgeForEntity(TenantInsurance::TYPE_DOCTOR, $doctor->getId());
$this->doctorRepo->remove($doctor);
return $this->success(['message' => 'دکتر با موفقیت حذف شد']);
}
@@ -0,0 +1,51 @@
<?php
namespace App\Insurance\Service;
use App\Insurance\Entity\EntityInsurancePricing;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Entity\TenantServiceCoverage;
use Doctrine\ORM\EntityManagerInterface;
/**
* Removes the insurance configuration a doctor/clinic owns when that entity is
* deleted. The owning columns (entity_type, entity_id) are polymorphic
* (doctor|clinic), so no database FK can cascade this — the cleanup is enforced
* here in the application. Coverage rows are removed via their owning
* tenant_insurance ids.
*/
final class TenantInsuranceCleanupService
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function purgeForEntity(string $entityType, int $entityId): void
{
$tenantInsuranceIds = $this->em->createQuery(
'SELECT t.id FROM ' . TenantInsurance::class . ' t
WHERE t.entityType = :type AND t.entityId = :id'
)->setParameter('type', $entityType)
->setParameter('id', $entityId)
->getSingleColumnResult();
if ($tenantInsuranceIds !== []) {
$this->em->createQuery(
'DELETE ' . TenantServiceCoverage::class . ' c
WHERE c.tenantInsuranceId IN (:ids)'
)->setParameter('ids', $tenantInsuranceIds)->execute();
}
$this->em->createQuery(
'DELETE ' . TenantInsurance::class . ' t
WHERE t.entityType = :type AND t.entityId = :id'
)->setParameter('type', $entityType)
->setParameter('id', $entityId)
->execute();
$this->em->createQuery(
'DELETE ' . EntityInsurancePricing::class . ' p
WHERE p.entityType = :type AND p.entityId = :id'
)->setParameter('type', $entityType)
->setParameter('id', $entityId)
->execute();
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Tests\Insurance;
use App\Insurance\Entity\EntityInsurancePricing;
use App\Insurance\Entity\TenantInsurance;
use App\Insurance\Entity\TenantServiceCoverage;
use App\Insurance\Service\TenantInsuranceCleanupService;
use App\Tests\ApiTestCase;
/**
* Deleting a doctor/clinic must purge the insurance config it owns. entity_id is
* polymorphic, so this cleanup is enforced in the application, not by a DB FK.
*/
class TenantInsuranceCleanupTest extends ApiTestCase
{
public function testPurgeRemovesTenantInsurancePricingAndCoverage(): void
{
$entityType = TenantInsurance::TYPE_DOCTOR;
$entityId = random_int(1_000_000, 9_999_999); // a doctor id not shared with other tests
$tenant = new TenantInsurance($entityType, $entityId, 1);
$this->em->persist($tenant);
$this->em->flush();
$coverage = new TenantServiceCoverage($tenant->getId(), 1);
$this->em->persist($coverage);
$this->em->persist(new EntityInsurancePricing($entityType, $entityId, 1));
$this->em->flush();
static::getContainer()->get(TenantInsuranceCleanupService::class)
->purgeForEntity($entityType, $entityId);
$this->em->clear();
$this->assertCount(0, $this->em->getRepository(TenantInsurance::class)
->findBy(['entityType' => $entityType, 'entityId' => $entityId]));
$this->assertCount(0, $this->em->getRepository(EntityInsurancePricing::class)
->findBy(['entityType' => $entityType, 'entityId' => $entityId]));
$this->assertNull($this->em->getRepository(TenantServiceCoverage::class)
->findOneBy(['tenantInsuranceId' => $tenant->getId()]));
}
}