Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions PROJECT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,13 @@ expiry sweeper. 14 EF migrations.
**23 tables** = 7 ASP.NET Identity + 16 domain. PostgreSQL, client-generated `uuid` keys
(`ValueGeneratedNever`), money `numeric(12,2)`, `timestamptz`.

> **DB integrity hardening** (migration `AddIntegrityConstraintsAndIndexTuning`): DB-level **CHECK
> constraints** now back the domain rules (money ≥ 0, `review.Rating` 1–5, currency `^[A-Z]{3}$`,
> status enums in their valid set); **payment/refund FKs are `Restrict`** + a real `refund→booking`
> FK so financial history can't be deleted; and `booking`/`trip`/`AspNetUsers` indexes were
> **right-sized** (report composites added, redundant single-column indexes dropped — faster reads,
> no extra write cost). Covered by `DbConstraintsTests`.
Comment on lines +131 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid overstating test coverage in the hardening summary.

At Line 136, “Covered by DbConstraintsTests” reads as full coverage, but tests currently validate a subset (RESTRICT delete, payment amount CHECK, review rating CHECK). Consider clarifying this as partial coverage.

Suggested wording
-> **right-sized** (report composites added, redundant single-column indexes dropped — faster reads,
-> no extra write cost). Covered by `DbConstraintsTests`.
+> **right-sized** (report composites added, redundant single-column indexes dropped — faster reads,
+> no extra write cost). Partially covered by `DbConstraintsTests` (RESTRICT + representative CHECKs).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@PROJECT_STATUS.md` around lines 131 - 136, The summary overstates test
coverage by saying "Covered by `DbConstraintsTests`" even though
`DbConstraintsTests` only verifies a subset (e.g., RESTRICT delete behavior,
payment amount CHECK, review rating CHECK); update the wording in the
`AddIntegrityConstraintsAndIndexTuning` section to indicate partial rather than
full coverage (e.g., "Partial coverage by `DbConstraintsTests` — tests validate
RESTRICT delete, payment amount CHECK, and review rating CHECK"), and mention
remaining untested items (currency regex, status enums, refund→booking FK, index
changes) so readers know what `DbConstraintsTests` actually covers.


### 4.1 Identity tables
`AspNetUsers` (+ custom `FullName`, `CompanyId`, **`StaffType`**), `AspNetRoles`, `AspNetUserRoles`,
`AspNetUserClaims`, `AspNetRoleClaims`, **`AspNetUserLogins`** (backs Google external login), `AspNetUserTokens`.
Expand All @@ -138,12 +145,12 @@ expiry sweeper. 14 EF migrations.
- **driver** *(new)* — Id · CompanyId FK · FullName · Phone? · LicenseNumber? · audit · index on `CompanyId`.
- **trip** — Id · CompanyId FK · BusId FK · Origin · Destination · DepartureUtc · ArrivalUtc · SeatCount · Price · Currency · Status · audit · indexes on `CompanyId`, `BusId`, and functional partial `(lower(Origin), lower(Destination), DepartureUtc) WHERE Status='Scheduled'`.
- **trip_stop** *(new)* — Id · TripId FK (Cascade) · Sequence · Name · ArrivalUtc? · DepartureUtc? · **UNIQUE(TripId, Sequence)** (ordered waypoints between origin and destination).
- **booking** — Id · TripId FK · CustomerEmail · Reference **UNIQUE** · Status · TotalAmount · Currency · **`PromoCode`? · `DiscountAmount`** · IdempotencyKey **UNIQUE** · audit · indexes on `CustomerEmail`, `TripId`.
- **booking** — Id · TripId FK · CustomerEmail · Reference **UNIQUE** · Status · TotalAmount · Currency · **`PromoCode`? · `DiscountAmount`** · IdempotencyKey **UNIQUE** · audit · indexes on `CustomerEmail`, `CreatedAtUtc`, composite `(TripId, Status)` and `(CreatedBy, CreatedAtUtc)` (the standalone `TripId`/`Status`/`CreatedBy` indexes were folded into these composites — leaner writes on a hot table) · **CHECK** amounts ≥ 0, currency `^[A-Z]{3}$`, status in the valid set.
- **passenger** — Id · BookingId FK (Cascade) · FirstName · LastName · SeatNumber · **`DocumentType`? · `DocumentNumber`?** (optional travel ID).
- **seat_hold** — Id · TripId FK · SeatNumber · HeldBy · BookingId? · ExpiresAtUtc · Consumed · audit · **FILTERED UNIQUE(TripId, SeatNumber) WHERE Consumed=false** · index on `ExpiresAtUtc`.
- **seat_assignment** — Id · TripId FK · SeatNumber · BookingId FK (Cascade) · **UNIQUE(TripId, SeatNumber)** (no-overbooking guarantee).
- **payment** — Id · BookingId FK **UNIQUE** · Gateway · GatewayTxnRef · Status (Pending/Completed/Failed/Refunded) · Amount · Currency · IdempotencyKey **UNIQUE** · audit. No card data (PCI SAQ-A).
- **refund** *(new)* — Id · PaymentId FK (Cascade) · BookingId · Amount · Currency · Status (Pending/Completed/Failed) · GatewayRefundRef? · Reason · IdempotencyKey **UNIQUE** · audit · index on `PaymentId`.
- **payment** — Id · BookingId FK (**Restrict** — never erase a payment by deleting its booking) **UNIQUE** · Gateway · GatewayTxnRef · Status (Pending/Completed/Failed/Refunded) · Amount · Currency · IdempotencyKey **UNIQUE** · audit · **CHECK** amount ≥ 0, currency `^[A-Z]{3}$`, status valid. No card data (PCI SAQ-A).
- **refund** *(new)* — Id · PaymentId FK (**Restrict**) · BookingId FK (**Restrict** — was an unconstrained column, now a real FK + `IX_refund_BookingId`) · Amount · Currency · Status (Pending/Completed/Failed) · GatewayRefundRef? · Reason · IdempotencyKey **UNIQUE** · audit · index on `PaymentId` · **CHECK** amount ≥ 0, currency `^[A-Z]{3}$`, status valid.
- **notification** *(new)* — Id · RecipientUserId · Title · Message · Type · IsRead · ReadAtUtc? · audit · indexes `(RecipientUserId, IsRead)` and `(RecipientUserId, CreatedAtUtc)`.
- **review** *(new)* — Id · BookingId FK **UNIQUE** · TripId · CompanyId · CustomerEmail (private) · DisplayName (first name + last initial) · Rating (1–5) · Comment? · audit · indexes on `TripId`, `CompanyId`.
- **promo_code** *(new)* — Id · CompanyId FK (Cascade) · Code · DiscountType (Percent/Fixed) · DiscountValue · MaxRedemptions? · RedemptionCount · ExpiresAtUtc? · Active · audit · **UNIQUE(CompanyId, Code)**; over-redemption prevented by an atomic conditional UPDATE.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ internal sealed class ApplicationUserConfiguration : IEntityTypeConfiguration<Ap
public void Configure(EntityTypeBuilder<ApplicationUser> builder)
{
builder.Property(u => u.StaffType).HasConversion<string>().HasMaxLength(20);
// Vendor staff/manager lookups and company-delete all filter users by company.
builder.HasIndex(u => u.CompanyId);
// Staff listings filter by (CompanyId, StaffType != null); company-delete filters CompanyId.
// The composite serves both — CompanyId-only via its leftmost prefix — so no separate
// single-column CompanyId index is needed.
builder.HasIndex(u => new { u.CompanyId, u.StaffType });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@ internal sealed class BookingConfiguration : IEntityTypeConfiguration<Booking>
{
public void Configure(EntityTypeBuilder<Booking> builder)
{
builder.ToTable("booking");
builder.ToTable("booking", t =>
{
// DB-level invariants: money is never negative and Status/Currency are well-formed.
t.HasCheckConstraint(
"CK_booking_amounts_nonneg", "\"TotalAmount\" >= 0 AND \"DiscountAmount\" >= 0");
t.HasCheckConstraint("CK_booking_currency_format", "\"Currency\" ~ '^[A-Z]{3}$'");
t.HasCheckConstraint(
"CK_booking_status_valid",
"\"Status\" IN ('PendingPayment', 'Confirmed', 'Cancelled', 'Expired')");
});
builder.HasKey(b => b.Id);
builder.Property(b => b.CustomerEmail).HasMaxLength(256).IsRequired();
builder.Property(b => b.Reference).HasMaxLength(40).IsRequired();
Expand All @@ -23,11 +32,14 @@ public void Configure(EntityTypeBuilder<Booking> builder)
builder.HasIndex(b => b.Reference).IsUnique();
builder.HasIndex(b => b.IdempotencyKey).IsUnique(); // idempotent booking creation
builder.HasIndex(b => b.CustomerEmail);
// Report access paths: status counts (admin/vendor summaries), date-range scans + ordering
// (booking report), and the per-operator grouping (employee report).
builder.HasIndex(b => b.Status);
// Report access paths. Composite indexes carry leftmost-prefix coverage, so we deliberately
// do NOT keep single-column Status/CreatedBy/TripId indexes too (dead write-weight on this
// hot table): (TripId, Status) covers confirmed-booking counts AND TripId-only lookups + the
// trip FK; (CreatedBy, CreatedAtUtc) covers the per-operator employee report; CreatedAtUtc
// alone stays for admin date-range scans.
builder.HasIndex(b => new { b.TripId, b.Status });
builder.HasIndex(b => b.CreatedAtUtc);
builder.HasIndex(b => b.CreatedBy);
builder.HasIndex(b => new { b.CreatedBy, b.CreatedAtUtc });

// FK → trip, Restrict: a trip with bookings can't be hard-deleted.
builder.HasOne<Trip>().WithMany()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ internal sealed class CompanyConfiguration : IEntityTypeConfiguration<Company>
{
public void Configure(EntityTypeBuilder<Company> builder)
{
builder.ToTable("company");
builder.ToTable("company", t =>
t.HasCheckConstraint("CK_company_status_valid", "\"Status\" IN ('Pending', 'Active', 'Suspended')"));
builder.HasKey(c => c.Id);
builder.Property(c => c.Name).HasMaxLength(200).IsRequired();
builder.Property(c => c.Email).HasMaxLength(256).IsRequired();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ internal sealed class PaymentConfiguration : IEntityTypeConfiguration<Payment>
{
public void Configure(EntityTypeBuilder<Payment> builder)
{
builder.ToTable("payment");
builder.ToTable("payment", t =>
{
t.HasCheckConstraint("CK_payment_amount_nonneg", "\"Amount\" >= 0");
t.HasCheckConstraint("CK_payment_currency_format", "\"Currency\" ~ '^[A-Z]{3}$'");
t.HasCheckConstraint(
"CK_payment_status_valid",
"\"Status\" IN ('Pending', 'Completed', 'Failed', 'Refunded')");
});
builder.HasKey(p => p.Id);
builder.Property(p => p.Gateway).HasMaxLength(50).IsRequired();
builder.Property(p => p.GatewayTxnRef).HasMaxLength(200);
Expand All @@ -23,9 +30,10 @@ public void Configure(EntityTypeBuilder<Payment> builder)
builder.HasIndex(p => p.IdempotencyKey).IsUnique();
builder.HasIndex(p => p.GatewayTxnRef);

// FK → booking, Cascade: a payment record belongs to its booking.
// FK → booking, Restrict: deleting a booking must never silently erase its payment record —
// financial history has to survive. Bookings are cancelled (a status change), not hard-deleted.
builder.HasOne<Booking>().WithMany()
.HasForeignKey(p => p.BookingId)
.OnDelete(DeleteBehavior.Cascade);
.OnDelete(DeleteBehavior.Restrict);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ internal sealed class PromoCodeConfiguration : IEntityTypeConfiguration<PromoCod
{
public void Configure(EntityTypeBuilder<PromoCode> builder)
{
builder.ToTable("promo_code");
builder.ToTable("promo_code", t =>
t.HasCheckConstraint("CK_promo_code_discount_nonneg", "\"DiscountValue\" >= 0"));
builder.HasKey(p => p.Id);
builder.Property(p => p.Code).HasMaxLength(PromoCode.MaxCodeLength).IsRequired();
builder.Property(p => p.DiscountType).HasConversion<string>().HasMaxLength(20).IsRequired();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TransportPlatform.Domain.Bookings;
using TransportPlatform.Domain.Payments;

namespace TransportPlatform.Infrastructure.Persistence.Configurations;
Expand All @@ -8,7 +9,13 @@ internal sealed class RefundConfiguration : IEntityTypeConfiguration<Refund>
{
public void Configure(EntityTypeBuilder<Refund> builder)
{
builder.ToTable("refund");
builder.ToTable("refund", t =>
{
t.HasCheckConstraint("CK_refund_amount_nonneg", "\"Amount\" >= 0");
t.HasCheckConstraint("CK_refund_currency_format", "\"Currency\" ~ '^[A-Z]{3}$'");
t.HasCheckConstraint(
"CK_refund_status_valid", "\"Status\" IN ('Pending', 'Completed', 'Failed')");
});
builder.HasKey(r => r.Id);
builder.Property(r => r.Amount).HasColumnType("numeric(12,2)").IsRequired();
builder.Property(r => r.Currency).HasMaxLength(3).IsRequired();
Expand All @@ -21,9 +28,16 @@ public void Configure(EntityTypeBuilder<Refund> builder)
builder.HasIndex(r => r.IdempotencyKey).IsUnique(); // a retry can't double-refund
builder.HasIndex(r => r.GatewayRefundRef);

// FK → payment (no navigation). Cascade: a refund is meaningless without its payment.
// FK → payment (no navigation). Restrict: a refund is part of the financial record and must
// not be erased by deleting its payment.
builder.HasOne<Payment>().WithMany()
.HasForeignKey(r => r.PaymentId)
.OnDelete(DeleteBehavior.Cascade);
.OnDelete(DeleteBehavior.Restrict);

// FK → booking (no navigation). The refund already stores BookingId; back it with a real
// constraint so it can never reference a missing booking (auto-creates IX_refund_BookingId).
builder.HasOne<Booking>().WithMany()
.HasForeignKey(r => r.BookingId)
.OnDelete(DeleteBehavior.Restrict);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ internal sealed class ReviewConfiguration : IEntityTypeConfiguration<Review>
{
public void Configure(EntityTypeBuilder<Review> builder)
{
builder.ToTable("review");
builder.ToTable("review", t =>
t.HasCheckConstraint("CK_review_rating_range", "\"Rating\" BETWEEN 1 AND 5"));
builder.HasKey(r => r.Id);
builder.Property(r => r.CustomerEmail).HasMaxLength(256).IsRequired();
builder.Property(r => r.DisplayName).HasMaxLength(120).IsRequired();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ internal sealed class TripConfiguration : IEntityTypeConfiguration<Trip>
{
public void Configure(EntityTypeBuilder<Trip> builder)
{
builder.ToTable("trip");
builder.ToTable("trip", t =>
{
// DB-level invariants (defence in depth beyond the domain guards): money is never
// negative, currency is a 3-letter uppercase code, and Status is a known value.
t.HasCheckConstraint("CK_trip_price_nonneg", "\"Price\" >= 0");
t.HasCheckConstraint("CK_trip_currency_format", "\"Currency\" ~ '^[A-Z]{3}$'");
t.HasCheckConstraint(
"CK_trip_status_valid",
"\"Status\" IN ('Scheduled', 'InProgress', 'Completed', 'Cancelled')");
});
builder.HasKey(t => t.Id);
builder.Property(t => t.Origin).HasMaxLength(120).IsRequired();
builder.Property(t => t.Destination).HasMaxLength(120).IsRequired();
Expand All @@ -23,7 +32,11 @@ public void Configure(EntityTypeBuilder<Trip> builder)
// lower(origin)/lower(destination) and only ever wants Scheduled trips, so the index is
// (lower("Origin"), lower("Destination"), "DepartureUtc") WHERE "Status" = 'Scheduled'.
// A plain (Origin,Destination,DepartureUtc) index would NOT be used for the lower() compare.
builder.HasIndex(t => t.CompanyId);
//
// (CompanyId, DepartureUtc) serves the per-company report date-range scans AND the vendor
// trip list (filter CompanyId, order by DepartureUtc); CompanyId-only lookups + the FK use
// its leftmost prefix, so no separate single-column CompanyId index is needed.
builder.HasIndex(t => new { t.CompanyId, t.DepartureUtc });

// Waypoints are owned by the trip: loaded via the backing field, cascade-deleted with it.
builder.HasMany(t => t.Stops)
Expand Down
Loading