fix(db): business-key unique constraints (M16-M19)

Add unique constraints (one migration, no dup data in either DB):
- users.email, users.national_code (M16) — NULLs still allowed.
- payments.gateway_token (M17).
- date_overrides (doctor_id, date) (M18) — was a non-unique index.
- financial_breakdowns (payment_id, source) (M19) — anti double-accounting.

Regression: tests/Database/UniqueConstraintsTest (4 duplicate-insert cases).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 21:03:54 +03:30
co-authored by Claude Opus 4.8
parent ccb71e4371
commit 96a86dbfed
7 changed files with 122 additions and 8 deletions
+4 -4
View File
@@ -69,10 +69,10 @@ _None outstanding._
| ⚠️M13 | Missing index: `ServiceItem.section_id` FK fully unindexed (entity has zero indexes) | src/ClinicService/Entity/ServiceItem.php:23-24 | perf-index | EXPLAIN findBySection → no full scan |
| ✅M14 | Missing index: `WalletTransaction(user_id, type)` composite for per-type SUM | src/Settlement/Entity/WalletTransaction.php:38-39 | perf-index | **DONE** — added composite `idx_wallet_user_type` + migration. `InfraSmokeTest::testWalletUserTypeIndexExists`. |
| ⚠️M15 | Missing index: `ClinicDoctorInvitation.doctor_id` FK unindexed | src/ClinicInvitation/Entity/ClinicDoctorInvitation.php:41-42 | perf-index | **FALSE POSITIVE**`doctor_id` has a FK → auto-indexed (IDX_26DCCFEB87F4FB17 exists). No change. |
| M16 | `User.email` and `User.nationalCode` not unique → duplicate identities | src/Auth/Entity/User.php:31-32,37-38 | db-unique | Insert two users same email/national_code → 2nd rejected. ⚠️ check dup data first |
| M17 | `Payment.gatewayToken` not unique | src/Payment/Entity/Payment.php:58-59 | db-unique | Two payments same gateway_token → rejected |
| M18 | `DateOverride` no UNIQUE `(doctor_id, date)` → ambiguous schedule | src/Appointment/Entity/DateOverride.php:12 | db-unique | Two overrides same doctor+date → rejected |
| M19 | `FinancialBreakdown` no unique `(payment_id, source)` → double-accounting | src/Settlement/Entity/FinancialBreakdown.php:28-33 | db-unique | Two breakdowns same payment+source → rejected |
| M16 | `User.email` and `User.nationalCode` not unique → duplicate identities | src/Auth/Entity/User.php:31-32,37-38 | db-unique | **DONE** — unique on email + national_code (NULLs ok; no dup data). `tests/Database/UniqueConstraintsTest` |
| M17 | `Payment.gatewayToken` not unique | src/Payment/Entity/Payment.php:58-59 | db-unique | **DONE** — unique gateway_token. UniqueConstraintsTest |
| M18 | `DateOverride` no UNIQUE `(doctor_id, date)` → ambiguous schedule | src/Appointment/Entity/DateOverride.php:12 | db-unique | **DONE** — UNIQUE(doctor_id,date). UniqueConstraintsTest |
| M19 | `FinancialBreakdown` no unique `(payment_id, source)` → double-accounting | src/Settlement/Entity/FinancialBreakdown.php:28-33 | db-unique | **DONE** — UNIQUE(payment_id,source). UniqueConstraintsTest |
| M20 | Soft-ref FKs orphan (Billing/ClinicService): `ClaimItem.invoice_item_id`, `Claim.insurance_id`, `Tariff.service_item_id`, `TenantServiceCoverage.service_item_id`, `DoctorAddress.clinicId`, polymorphic `SmsWallet`, `ClinicStaff` | src/Billing/Entity/ClaimItem.php:22 · Claim.php:48 · ClinicService/Entity/Tariff.php:23 · Insurance/Entity/TenantServiceCoverage.php:26 · Doctor/Entity/DoctorAddress.php:32 · Sms/Entity/SmsWallet.php:18-22 · Staff/Entity/ClinicStaff.php:22-26 | db-ondelete | Delete parent → child handled (cascade/restrict/set-null), no orphan |
| M21 | ~30 ad-hoc raw error codes not in ErrorCodes.php (USER_NOT_FOUND, INVALID_ROLE, SLOT_TAKEN, DUPLICATE_REQUEST, ERR_GONE, ERR_ACCESS_DENIED, …) → `message()` returns "خطای ناشناخته" | src/Admin/Controller/AdminApiController.php (many) · MyAppointmentsController.php:41-80 · PreRegistrationController.php:66 · CategoryController.php:36-40 · ClinicInvitationController.php:203 | quality-errorcodes | Trigger each → code present in ErrorCodes.php |
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260628173151 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE date_overrides DROP INDEX idx_date_overrides_doctor_date, ADD UNIQUE INDEX uniq_date_override_doctor_date (doctor_id, date)');
$this->addSql('CREATE UNIQUE INDEX uniq_breakdown_payment_source ON financial_breakdowns (payment_id, source)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_65D29B3223882363 ON payments (gateway_token)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9D3C17DD2 ON users (national_code)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE date_overrides DROP INDEX uniq_date_override_doctor_date, ADD INDEX idx_date_overrides_doctor_date (doctor_id, date)');
$this->addSql('DROP INDEX uniq_breakdown_payment_source ON financial_breakdowns');
$this->addSql('DROP INDEX UNIQ_65D29B3223882363 ON payments');
$this->addSql('DROP INDEX UNIQ_1483A5E9E7927C74 ON users');
$this->addSql('DROP INDEX UNIQ_1483A5E9D3C17DD2 ON users');
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: DateOverrideRepository::class)]
#[ORM\Table(name: 'date_overrides')]
#[ORM\Index(columns: ['doctor_id', 'date'], name: 'idx_date_overrides_doctor_date')]
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_date', columns: ['doctor_id', 'date'])]
class DateOverride
{
#[ORM\Id]
+2 -2
View File
@@ -28,13 +28,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(name: 'password_hash', type: 'string', length: 255, nullable: true)]
private ?string $passwordHash = null;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
#[ORM\Column(type: 'string', length: 100, nullable: true, unique: true)]
private ?string $email = null;
#[ORM\Column(name: 'real_name', type: 'string', length: 100, nullable: true)]
private ?string $realName = null;
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)]
#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true, unique: true)]
private ?string $nationalCode = null;
#[ORM\Column(name: 'national_code_verified', type: 'boolean')]
+1 -1
View File
@@ -55,7 +55,7 @@ class Payment
#[ORM\Column(type: 'string', length: 30)]
private string $type;
#[ORM\Column(name: 'gateway_token', type: 'string', length: 255, nullable: true)]
#[ORM\Column(name: 'gateway_token', type: 'string', length: 255, nullable: true, unique: true)]
private ?string $gatewayToken = null;
#[ORM\Column(name: 'reference_id', type: 'string', length: 255, nullable: true, unique: true)]
@@ -12,6 +12,7 @@ use Symfony\Component\Uid\Uuid;
#[ORM\Table(name: 'financial_breakdowns')]
#[ORM\Index(columns: ['representation_id', 'created_at'], name: 'idx_breakdown_rep_date')]
#[ORM\Index(columns: ['source', 'created_at'], name: 'idx_breakdown_source_date')]
#[ORM\UniqueConstraint(name: 'uniq_breakdown_payment_source', columns: ['payment_id', 'source'])]
class FinancialBreakdown
{
public const SOURCE_APPOINTMENT = 'appointment';
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Tests\Database;
use App\Appointment\Entity\DateOverride;
use App\Doctor\Entity\Doctor;
use App\Payment\Entity\Payment;
use App\Settlement\Entity\FinancialBreakdown;
use App\Tests\ApiTestCase;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
/**
* Business-key uniqueness: duplicate email / national_code / gateway_token,
* a second date-override for the same (doctor, date), and a second financial
* breakdown for the same (payment, source) must all be rejected at the DB.
*/
class UniqueConstraintsTest extends ApiTestCase
{
public function testDuplicateEmailRejected(): void
{
$email = 'dup' . bin2hex(random_bytes(5)) . '@test.local';
$this->createUser()->setEmail($email);
$this->createUser()->setEmail($email);
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
public function testDuplicateGatewayTokenRejected(): void
{
$token = 'tok-' . bin2hex(random_bytes(6));
$a = new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION);
$a->setGatewayToken($token);
$b = new Payment($this->createUser(), 1000, 'mellat', Payment::TYPE_SUBSCRIPTION);
$b->setGatewayToken($token);
$this->em->persist($a);
$this->em->persist($b);
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
public function testDuplicateDateOverrideRejected(): void
{
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر');
$this->em->persist($doctor);
$this->em->flush();
$date = time();
$this->em->persist(new DateOverride($doctor, $date));
$this->em->persist(new DateOverride($doctor, $date));
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
public function testDuplicateBreakdownPaymentSourceRejected(): void
{
$user = $this->createUser();
$payment = new Payment($user, 100_000, 'mellat', Payment::TYPE_APPOINTMENT);
$this->em->persist($payment);
$this->em->flush();
$make = fn () => new FinancialBreakdown(
$payment, FinancialBreakdown::SOURCE_APPOINTMENT, $user,
100_000, 0, '0.00', 0, 100_000, '10.00', 10_000, 90_000, null, null, null,
);
$this->em->persist($make());
$this->em->persist($make());
$this->expectException(UniqueConstraintViolationException::class);
$this->em->flush();
}
}