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:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user