Add Deployment
This commit is contained in:
@@ -0,0 +1,775 @@
|
||||
|
||||
### ⚠ Important Tip
|
||||
If your `agent.ts` uses `useAuthStore().token` **at the top level**, remember:
|
||||
- On very first load, Zustand will **rehydrate** from storage _after_ initial render, so token may be `null` until rehydration is done.
|
||||
- Fix: either delay access until after `hasHydrated`, or refactor `agent` to inject token per request.
|
||||
|
||||
|
||||
|
||||
|
||||
To **safely handle concurrency** in your `CreateTicketOrderAsync` method — particularly for **seat reservation** on the same `Transportation` — you need to **prevent race conditions** where two users might reserve the same seat or oversell capacity.
|
||||
|
||||
This is a **classic critical section problem**, and you can solve it using **application-level locking**, **database-level locking**, or both.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Recommended: **Database-level concurrency control + optional distributed lock**
|
||||
|
||||
### 👇 Here’s what you should do:
|
||||
|
||||
---
|
||||
|
||||
## ✅ 1. Use **pessimistic concurrency** (row-level locking) on the `Transportation` row
|
||||
|
||||
### Why?
|
||||
|
||||
You need to **lock the transportation row** to prevent multiple reservations from modifying the related seats/capacity **at the same time**.
|
||||
|
||||
### How?
|
||||
|
||||
Use EF Core's **raw SQL or transaction-scoped SELECT ... FOR UPDATE** (which translates to SQL row locks):
|
||||
|
||||
```csharp
|
||||
using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);
|
||||
|
||||
var transportation = await _context.Transportations
|
||||
.FromSqlRaw("SELECT * FROM Transportations WITH (UPDLOCK, ROWLOCK) WHERE Id = {0}", dto.TransportationId)
|
||||
.FirstOrDefaultAsync();
|
||||
```
|
||||
|
||||
- `UPDLOCK`: prevents others from reading for update
|
||||
|
||||
- `ROWLOCK`: locks only the specific row
|
||||
|
||||
|
||||
> If using PostgreSQL or MySQL, you’d use `FOR UPDATE` instead.
|
||||
|
||||
---
|
||||
|
||||
## ✅ 2. Alternatively, use **distributed locking** if you have multiple app instances
|
||||
|
||||
Use something like:
|
||||
|
||||
- **Redis distributed lock**
|
||||
|
||||
- **SQL application locks** (`sp_getapplock`)
|
||||
|
||||
- **SemaphoreSlim static lock** (only for single-instance apps — not safe in web farms)
|
||||
|
||||
|
||||
### Example using `sp_getapplock` (SQL Server):
|
||||
|
||||
```sql
|
||||
EXEC sp_getapplock @Resource = 'lock_transportation_123', @LockMode = 'Exclusive', @LockTimeout = 10000;
|
||||
```
|
||||
|
||||
EF version:
|
||||
|
||||
```csharp
|
||||
await _dbContext.Database.ExecuteSqlRawAsync(
|
||||
$"EXEC sp_getapplock @Resource = 'transportation_lock_{dto.TransportationId}', @LockMode = 'Exclusive', @LockTimeout = 10000;");
|
||||
```
|
||||
|
||||
✅ This will ensure **only one thread** gets to proceed per `TransportationId`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ 3. Wrap the reservation logic in a **transaction**
|
||||
|
||||
```csharp
|
||||
using var transaction = await _context.Database.BeginTransactionAsync(IsolationLevel.Serializable);
|
||||
|
||||
// locking query here
|
||||
// seat validation
|
||||
// insert tickets
|
||||
// save ticket order
|
||||
// commit
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
```
|
||||
|
||||
Use `IsolationLevel.Serializable` or `RepeatableRead` if you’re not doing explicit locking.
|
||||
|
||||
---
|
||||
|
||||
## ✅ 4. Add a **unique constraint** on `SeatId + TransportationId` (if not already)
|
||||
|
||||
To **enforce uniqueness at DB level** and prevent race conditions from succeeding:
|
||||
|
||||
```sql
|
||||
ALTER TABLE Tickets ADD CONSTRAINT UQ_Tickets_Seat_Transport UNIQUE (SeatId, TicketStatusId)
|
||||
WHERE TicketStatusId != 3; -- exclude cancelled
|
||||
```
|
||||
|
||||
Or make a logic-safe alternative depending on your schema.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Final version: Summary of changes
|
||||
|
||||
### In your service:
|
||||
|
||||
```csharp
|
||||
using var transaction = await _dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable);
|
||||
|
||||
// Optional: Lock the transportation row to prevent race
|
||||
await _dbContext.Database.ExecuteSqlRawAsync(
|
||||
$"EXEC sp_getapplock @Resource = 'transportation_lock_{dto.TransportationId}', @LockMode = 'Exclusive', @LockTimeout = 10000;");
|
||||
|
||||
// Re-fetch transportation + tickets
|
||||
var transportation = await _transportationRepository.GetByIdWithTickets(dto.TransportationId);
|
||||
|
||||
// Validate seats
|
||||
var seatCheck = ValidateTransportationAndSeats(transportation, dto.Travelers);
|
||||
if (!string.IsNullOrEmpty(seatCheck)) return Result<long>.Error(0, seatCheck);
|
||||
|
||||
// Proceed with reservation
|
||||
...
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Summary Table
|
||||
|
||||
|Strategy|Prevents Race?|Web-farm Safe|Recommended For|
|
||||
|---|---|---|---|
|
||||
|`sp_getapplock`|✅ Yes|✅ Yes|SQL Server web apps|
|
||||
|EF raw SQL `WITH (UPDLOCK)`|✅ Yes|✅ Yes|SQL Server monoliths|
|
||||
|`SemaphoreSlim`|✅ Yes|❌ No|Local-only apps|
|
||||
|`FOR UPDATE`|✅ Yes|✅ Yes|PostgreSQL, MySQL|
|
||||
|
||||
---
|
||||
|
||||
Let me know your DB provider and environment (single-instance or multi-instance), and I can generate exact code tailored for that.
|
||||
Perfect — you're absolutely right to want **the whole process to be atomic**:
|
||||
→ **Seat allocation**,
|
||||
→ **Ticket creation**,
|
||||
→ **Balance checking & payment**,
|
||||
→ **Transaction creation**,
|
||||
→ **Traveler creation/upsert**, etc.
|
||||
|
||||
This needs more than just _database-level locks_. You want to **serialize execution** for the same transportation to avoid concurrency conflicts — **across the entire flow**, not just the DB writes.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Your Goal
|
||||
|
||||
> **For a given `TransportationId`, only one call to `CreateTicketOrderAsync` should execute at a time**, regardless of app instance or user.
|
||||
|
||||
This includes logic **outside EF** (like payment, seat assignment, person creation, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ The Right Solution: **Distributed Application Locking**
|
||||
|
||||
### Use **SQL Server’s `sp_getapplock`** to lock **by `TransportationId`**
|
||||
|
||||
Why?
|
||||
|
||||
- It locks **application-level logic**, not just SQL rows
|
||||
|
||||
- It works across threads, processes, and even app servers
|
||||
|
||||
- It ensures only **one process** can work on that transportation at a time
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ How to Implement in Your Method
|
||||
|
||||
### 🔒 Step 1: Lock by Transportation ID
|
||||
|
||||
Add this at the **start** of your method:
|
||||
|
||||
```csharp
|
||||
await _dbContext.Database.ExecuteSqlRawAsync(
|
||||
$"EXEC sp_getapplock @Resource = 'transportation_lock_{dto.TransportationId}', " +
|
||||
"@LockMode = 'Exclusive', @LockTimeout = 10000;");
|
||||
```
|
||||
|
||||
This ensures that **only one call per transportation** runs at a time.
|
||||
|
||||
If a second request tries to lock the same ID, it waits up to 10 seconds.
|
||||
|
||||
---
|
||||
|
||||
### 🔁 Step 2: Begin EF Transaction
|
||||
|
||||
This will include all EF operations in a single atomic unit:
|
||||
|
||||
```csharp
|
||||
using var transaction = await _dbContext.Database.BeginTransactionAsync();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🧠 Step 3: Keep the Full Logic As Is, But Within Transaction
|
||||
|
||||
Like this:
|
||||
|
||||
```csharp
|
||||
public async Task<Result<long>> CreateTicketOrderAsync(long accountId, CreateTicketOrderDto dto)
|
||||
{
|
||||
// 🔒 Lock to prevent concurrency per transportation
|
||||
await _dbContext.Database.ExecuteSqlRawAsync(
|
||||
$"EXEC sp_getapplock @Resource = 'transportation_lock_{dto.TransportationId}', " +
|
||||
"@LockMode = 'Exclusive', @LockTimeout = 10000;");
|
||||
|
||||
// 🧾 Begin transaction
|
||||
using var transaction = await _dbContext.Database.BeginTransactionAsync();
|
||||
|
||||
// 💰 Load account and transportation
|
||||
var account = await _accountRepository.GetByIdAsync(accountId);
|
||||
if (account == null) return Result<long>.Error(0, "Account not found");
|
||||
|
||||
var transportation = await _transportationRepository.GetByIdAsync(dto.TransportationId);
|
||||
if (transportation == null) return Result<long>.Error(0, "Transportation not found");
|
||||
|
||||
// 💵 Check balance
|
||||
var price = transportation.BasePrice * dto.Travelers.Count;
|
||||
if (account.CurrentBalance < price)
|
||||
return Result<long>.Error(0, "Not enough money");
|
||||
|
||||
// ✅ Validate seat availability
|
||||
var seatCheck = ValidateTransportationAndSeats(transportation, dto.Travelers);
|
||||
if (!string.IsNullOrEmpty(seatCheck))
|
||||
return Result<long>.Error(0, seatCheck);
|
||||
|
||||
// 🪑 Assign seats (if dynamic logic)
|
||||
await AssignSeatsIfDynamic(transportation.VehicleId, dto.Travelers);
|
||||
|
||||
// 👤 Upsert travelers (can also use same transaction)
|
||||
await UpsertTravelers(account.Id, dto.Travelers);
|
||||
|
||||
// 🎟️ Create ticket order
|
||||
var ticketOrder = new TicketOrder
|
||||
{
|
||||
BuyerId = account.Id,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Description = "",
|
||||
SerialNumber = Guid.NewGuid().ToString("N"),
|
||||
TransportationId = dto.TransportationId,
|
||||
};
|
||||
await _ticketOrderRepository.AddAsync(ticketOrder);
|
||||
|
||||
// 🎫 Add tickets
|
||||
foreach (var traveler in dto.Travelers)
|
||||
{
|
||||
var ticket = new Ticket
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Description = traveler.Description,
|
||||
SeatId = traveler.SeatId!.Value,
|
||||
SerialNumber = Guid.NewGuid().ToString("N"),
|
||||
TicketOrder = ticketOrder,
|
||||
TicketStatusId = 1,
|
||||
TravelerId = traveler.Id,
|
||||
};
|
||||
await _ticketRepository.AddAsync(ticket);
|
||||
}
|
||||
|
||||
// 💳 Commit everything
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// 💸 Withdraw money and create transaction
|
||||
await _accountService.PayForTicketOrderAsync(account.Id, ticketOrder.Id, price);
|
||||
|
||||
// ✅ Commit transaction
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return Result<long>.Success(ticketOrder.Id);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧱 Summary of Protection
|
||||
|
||||
|Step|Protected?|How|
|
||||
|---|---|---|
|
||||
|Prevent concurrent seat assignment|✅|`sp_getapplock` per `TransportationId`|
|
||||
|Prevent concurrent DB changes|✅|EF `DbTransaction`|
|
||||
|Prevent double-spend|✅|Balance check inside the same lock + transaction|
|
||||
|Ensure traveler upserts/tickets are safe|✅|Wrapped in same transaction|
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Important Note
|
||||
|
||||
Make sure all the operations (`AssignSeatsIfDynamic`, `UpsertTravelers`, etc.):
|
||||
|
||||
- Use the **same `DbContext`/UnitOfWork**
|
||||
|
||||
- Don’t commit changes prematurely (no `SaveChangesAsync()` before you're ready to commit the whole transaction)
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Optional (Safety): Add retry logic
|
||||
|
||||
In case of a **deadlock or lock timeout**, you might want to retry the whole operation once.
|
||||
|
||||
---
|
||||
|
||||
Let me know if you want me to extract this into a reusable helper like:
|
||||
|
||||
```csharp
|
||||
await _appLockService.ExecuteWithLockAsync($"transportation_lock_{dto.TransportationId}", async () => {
|
||||
// your reservation logic
|
||||
});
|
||||
```
|
||||
|
||||
That would clean things up nicely.
|
||||
Sure. Let’s break down everything you need to know about **transactions** in Entity Framework Core (EF Core) — especially for your case where you're doing **complex operations that must all succeed or fail together**.
|
||||
|
||||
---
|
||||
|
||||
## 🧾 What is a Transaction?
|
||||
|
||||
A **transaction** is a unit of work that is treated as a **single, indivisible operation**. Either **all of it succeeds** (committed), or **none of it does** (rolled back).
|
||||
|
||||
### 🧠 Properties of a transaction (ACID):
|
||||
|
||||
|Property|Meaning|
|
||||
|---|---|
|
||||
|**Atomicity**|All steps succeed or none do|
|
||||
|**Consistency**|DB remains valid before and after|
|
||||
|**Isolation**|Transactions don’t interfere with each other|
|
||||
|**Durability**|Once committed, data is persisted permanently|
|
||||
|
||||
---
|
||||
|
||||
## ✅ Why You Need Transactions
|
||||
|
||||
In your case, you’re:
|
||||
|
||||
- Assigning seats
|
||||
|
||||
- Creating or updating people
|
||||
|
||||
- Creating a `TicketOrder`
|
||||
|
||||
- Creating multiple `Tickets`
|
||||
|
||||
- Updating account balance & transaction history
|
||||
|
||||
|
||||
All of these must either:
|
||||
|
||||
- Complete **fully and safely**, or
|
||||
|
||||
- **Fail entirely**, with no partial data left behind.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 🧰 Transaction in EF Core: Full Example
|
||||
|
||||
### 🔧 Step-by-step Example:
|
||||
|
||||
```csharp
|
||||
public async Task<Result<long>> CreateTicketOrderAsync(long accountId, CreateTicketOrderDto dto)
|
||||
{
|
||||
// Begin EF Core transaction
|
||||
using var transaction = await _dbContext.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
// Load required entities
|
||||
var account = await _accountRepository.GetByIdAsync(accountId);
|
||||
if (account == null)
|
||||
return Result<long>.Error(0, "Account not found");
|
||||
|
||||
var transportation = await _transportationRepository.GetByIdAsync(dto.TransportationId);
|
||||
if (transportation == null)
|
||||
return Result<long>.Error(0, "Transportation not found");
|
||||
|
||||
var price = transportation.BasePrice * dto.Travelers.Count;
|
||||
if (account.CurrentBalance < price)
|
||||
return Result<long>.Error(0, "Not enough money");
|
||||
|
||||
// Validate seat assignment
|
||||
var seatCheck = ValidateTransportationAndSeats(transportation, dto.Travelers);
|
||||
if (!string.IsNullOrEmpty(seatCheck))
|
||||
return Result<long>.Error(0, seatCheck);
|
||||
|
||||
await AssignSeatsIfDynamic(transportation.VehicleId, dto.Travelers);
|
||||
await UpsertTravelers(account.Id, dto.Travelers);
|
||||
|
||||
// Create ticket order
|
||||
var ticketOrder = new TicketOrder
|
||||
{
|
||||
BuyerId = account.Id,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
SerialNumber = Guid.NewGuid().ToString("N"),
|
||||
TransportationId = dto.TransportationId,
|
||||
};
|
||||
await _ticketOrderRepository.AddAsync(ticketOrder);
|
||||
|
||||
foreach (var traveler in dto.Travelers)
|
||||
{
|
||||
await _ticketRepository.AddAsync(new Ticket
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Description = traveler.Description,
|
||||
SeatId = traveler.SeatId.Value,
|
||||
SerialNumber = Guid.NewGuid().ToString("N"),
|
||||
TicketOrder = ticketOrder,
|
||||
TicketStatusId = 1,
|
||||
TravelerId = traveler.Id,
|
||||
});
|
||||
}
|
||||
|
||||
// Save changes
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
// Withdraw money from account
|
||||
await _accountService.PayForTicketOrderAsync(account.Id, ticketOrder.Id, price);
|
||||
|
||||
// Commit transaction
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return Result<long>.Success(ticketOrder.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Rollback if anything failed
|
||||
await transaction.RollbackAsync();
|
||||
return Result<long>.Error(0, "Reservation failed: " + ex.Message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Isolation Levels (Advanced)
|
||||
|
||||
### You can specify isolation level when beginning a transaction:
|
||||
|
||||
```csharp
|
||||
using var transaction = await _dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable);
|
||||
```
|
||||
|
||||
### Common levels:
|
||||
|
||||
|Isolation Level|Description|Use Case|
|
||||
|---|---|---|
|
||||
|`ReadCommitted` (default)|No dirty reads|Usually enough|
|
||||
|`RepeatableRead`|Prevents non-repeatable reads|Seat assignment logic|
|
||||
|`Serializable`|Full isolation (slower, safer)|Financial or reservation systems|
|
||||
|
||||
---
|
||||
|
||||
## 🧩 When `SaveChangesAsync()` Commits vs. Not
|
||||
|
||||
Calling `SaveChangesAsync()` **does not commit** the outer transaction — it just writes pending changes to the database.
|
||||
|
||||
The real commit happens only when you call:
|
||||
|
||||
```csharp
|
||||
await transaction.CommitAsync();
|
||||
```
|
||||
|
||||
If anything fails before that, the changes are **not persisted**.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Best Practices for Transactions in EF Core
|
||||
|
||||
|Best Practice|Why|
|
||||
|---|---|
|
||||
|Keep transactions **short**|Locks and contention increase with time|
|
||||
|Catch exceptions and **rollback**|Prevent dirty/incomplete data|
|
||||
|Use **only one DbContext** inside the transaction|EF tracks entities via one unit of work|
|
||||
|Don’t call SaveChanges too early|May break the atomicity|
|
||||
|Use **appropriate isolation level**|Based on your concurrency needs|
|
||||
|Consider retry policy for transient errors|Especially in distributed systems|
|
||||
|
||||
---
|
||||
|
||||
## ❗ Common Pitfalls
|
||||
|
||||
|Mistake|Problem|
|
||||
|---|---|
|
||||
|Mixing DbContext instances inside one transaction|Breaks consistency|
|
||||
|Committing too early|Later failure still leaves partial data|
|
||||
|Forgetting rollback in catch|Leaves transaction open or inconsistent|
|
||||
|Async code outside transaction scope|Could break atomicity or introduce race|
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Summary: Transaction Essentials
|
||||
|
||||
|Concept|Description|
|
||||
|---|---|
|
||||
|`BeginTransactionAsync()`|Starts a manual transaction|
|
||||
|`SaveChangesAsync()`|Writes changes but doesn’t commit outer transaction|
|
||||
|`CommitAsync()`|Finalizes all DB changes|
|
||||
|`RollbackAsync()`|Reverts all changes since the transaction began|
|
||||
|Scope|All EF/DB actions done within the transaction block|
|
||||
|
||||
---
|
||||
|
||||
If you'd like, I can create a **generic transaction helper**, or even make this a **middleware** or **interceptor**, depending on how reusable you want this to be.
|
||||
|
||||
Let me know!
|
||||
Great! These questions show deep thinking about **how transactions and locks actually work** in EF Core and layered architecture. Let’s answer them one by one with real clarity and direction.
|
||||
|
||||
---
|
||||
|
||||
## ❓ 1. _"I have this method in my `TicketOrderService`, and I don't have access to `_dbContext`."_
|
||||
|
||||
### ✅ Solution:
|
||||
|
||||
If you're using **Repository + UnitOfWork pattern**, your `UnitOfWork` should expose a method to access the underlying **DbContext** or allow you to start a transaction.
|
||||
|
||||
**👉 Extend your `IUnitOfWork` interface like this:**
|
||||
|
||||
```csharp
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
Task<int> SaveChangesAsync();
|
||||
Task<IDbContextTransaction> BeginTransactionAsync(IsolationLevel level = IsolationLevel.ReadCommitted);
|
||||
DbContext Context { get; } // optional
|
||||
}
|
||||
```
|
||||
|
||||
And in your implementation:
|
||||
|
||||
```csharp
|
||||
public class UnitOfWork : IUnitOfWork
|
||||
{
|
||||
private readonly AppDbContext _context;
|
||||
|
||||
public UnitOfWork(AppDbContext context) => _context = context;
|
||||
|
||||
public Task<int> SaveChangesAsync() => _context.SaveChangesAsync();
|
||||
|
||||
public Task<IDbContextTransaction> BeginTransactionAsync(IsolationLevel level = IsolationLevel.ReadCommitted)
|
||||
=> _context.Database.BeginTransactionAsync(level);
|
||||
|
||||
public DbContext Context => _context;
|
||||
}
|
||||
```
|
||||
|
||||
### ✅ Then in your `TicketOrderService`:
|
||||
|
||||
```csharp
|
||||
using var transaction = await _unitOfWork.BeginTransactionAsync();
|
||||
// your operations
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
```
|
||||
|
||||
This keeps your `Service` clean and architecture-respecting.
|
||||
|
||||
---
|
||||
|
||||
## ❓ 2. _"How does the transaction hold stuff? Wouldn’t `unitOfWork.SaveChangesAsync()` just save everything?"_
|
||||
|
||||
### 🔍 Clarification:
|
||||
|
||||
- `SaveChangesAsync()` **writes the changes to the database**, but…
|
||||
|
||||
- **If you're in a transaction**, the database holds those changes in a **temporary, invisible state** until you explicitly call:
|
||||
|
||||
|
||||
```csharp
|
||||
await transaction.CommitAsync();
|
||||
```
|
||||
|
||||
💡 If you **don’t commit**, or you **rollback**, then **those changes are discarded**, even if you called `SaveChangesAsync()`.
|
||||
|
||||
### 👇 Timeline of how EF works in a transaction:
|
||||
|
||||
1. EF tracks entities in memory.
|
||||
|
||||
2. You call `SaveChangesAsync()`.
|
||||
|
||||
3. Changes are written to the DB — but:
|
||||
|
||||
- If inside a transaction, they are **not visible to others yet**.
|
||||
|
||||
- They are held until `CommitAsync()`.
|
||||
|
||||
4. If you call `RollbackAsync()`, all writes are undone.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ❓ 3. _"How would the lock know that the work with that transportation is finished?"_
|
||||
|
||||
### 🔒 About `sp_getapplock`:
|
||||
|
||||
This SQL Server feature locks a **named resource** (like `transportation_lock_123`) for **the duration of the current SQL connection and transaction**.
|
||||
|
||||
Here’s how it works:
|
||||
|
||||
|Action|Result|
|
||||
|---|---|
|
||||
|You call `sp_getapplock`|SQL Server grants an exclusive lock for the resource|
|
||||
|You complete the transaction (commit or rollback)|Lock is **automatically released**|
|
||||
|Your connection is closed or disposed|Lock is released|
|
||||
|
||||
### 🔍 So you don’t need to manually release the lock.
|
||||
|
||||
Just make sure:
|
||||
|
||||
- The **lock and transaction use the same connection**
|
||||
|
||||
- You commit or rollback after the work is done
|
||||
|
||||
|
||||
✅ EF Core handles all that automatically when using `Database.BeginTransactionAsync()`.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Final Advice Recap
|
||||
|
||||
|Question|Answer|
|
||||
|---|---|
|
||||
|I don’t have access to `_dbContext`|Expose `BeginTransactionAsync` on your `IUnitOfWork`|
|
||||
|How does transaction matter if I save mid-way?|It isolates changes — no one sees them until committed|
|
||||
|How does lock get released?|Automatically when transaction completes or connection closes|
|
||||
|
||||
---
|
||||
|
||||
Let me know if you want:
|
||||
|
||||
- Sample `UnitOfWork` and `Repository` setup for full support
|
||||
|
||||
- A lock + transaction helper method
|
||||
|
||||
- Unit tests to simulate concurrent reservations
|
||||
|
||||
|
||||
You're doing great — this is how robust, real-world software gets built.
|
||||
|
||||
You've unpacked crucial concerns around **transactions**, **unit-of-work (UoW)**, and **application-wide consistency**. Here's a polished breakdown with clear answers and guidance:
|
||||
|
||||
---
|
||||
|
||||
### ✅ 1. Is it okay to use both Transactions **and** Unit of Work?
|
||||
|
||||
- **Yes, absolutely.** UoW is a design pattern that groups multiple repository operations into one logical unit, while a _transaction_ ensures these operations either all succeed or all fail atomically ([antondevtips.com](https://antondevtips.com/blog/implementing-unit-of-work-pattern-in-ef-core?utm_source=chatgpt.com "Implementing Unit of Work Pattern in EF Core - Anton DevTips")).
|
||||
|
||||
- In EF Core, the **DbContext itself implements UoW**, collecting changes in memory until `SaveChangesAsync()` is called ([reddit.com](https://www.reddit.com/r/dotnet/comments/17npjtx/unit_of_work_pattern_still_necessary_in_net_8/?utm_source=chatgpt.com "Unit of Work Pattern still necessary in .NET 8? : r/dotnet - Reddit")).
|
||||
|
||||
- When you execute **multiple SaveChanges**, or include non-repository logic (e.g., balance updates, payment creation, seat locking), **explicit transactions** ensure the entire flow remains atomic .
|
||||
|
||||
|
||||
---
|
||||
|
||||
### ✅ 2. How exactly to open/close transactions? Where to be careful?
|
||||
|
||||
#### ✨ Use EF to manage transactions in your UoW:
|
||||
|
||||
1. **Extend your `IUnitOfWork`** to include transaction methods:
|
||||
|
||||
```csharp
|
||||
Task<IDbContextTransaction> BeginTransactionAsync(IsolationLevel level = ReadCommitted);
|
||||
Task<int> SaveChangesAsync();
|
||||
```
|
||||
|
||||
2. **In your service**, do:
|
||||
|
||||
```csharp
|
||||
using var tx = await _unitOfWork.BeginTransactionAsync();
|
||||
try {
|
||||
// perform all operations (seat locks, entity changes, balance, tickets)
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
} catch {
|
||||
await tx.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Know the boundaries**: Keep the transaction as short as possible. Open it before critical operations and close it after the final `CommitAsync()`.
|
||||
|
||||
|
||||
> 💡 Use `(IsolationLevel.Serializable)` for scenarios like seat reservation where concurrent writes must be prevented ([milanjovanovic.tech](https://www.milanjovanovic.tech/blog/working-with-transactions-in-ef-core?utm_source=chatgpt.com "Working With Transactions In EF Core"), [learn.microsoft.com](https://learn.microsoft.com/en-us/aspnet/mvc/overview/older-versions/getting-started-with-ef-5-using-mvc-4/implementing-the-repository-and-unit-of-work-patterns-in-an-asp-net-mvc-application?utm_source=chatgpt.com "Implementing the Repository and Unit of Work Patterns in an ASP ...")).
|
||||
|
||||
---
|
||||
|
||||
### ✅ 3. Why did things “work” without explicit transactions?
|
||||
|
||||
- **Simple SaveChanges** calls act like atomic units—if they fail, nothing is saved. With just one SaveChanges call, EF wraps it in a default transaction ([stackoverflow.com](https://stackoverflow.com/questions/24636352/unit-of-work-i-dont-need-to-use-transactions?utm_source=chatgpt.com "c# - unit of work - I don't need to use transactions? - Stack Overflow")).
|
||||
|
||||
- However, as soon as your workflow involves **multiple SaveChanges**, **external services**, or **cross-repository updates**, the default behavior is no longer sufficient. A failure midway could leave your data in a partial state.
|
||||
|
||||
- That's where **explicit transaction scopes** come in—to guarantee everything is batched and rolled back if any part fails ([antondevtips.com](https://antondevtips.com/blog/implementing-unit-of-work-pattern-in-ef-core?utm_source=chatgpt.com "Implementing Unit of Work Pattern in EF Core - Anton DevTips")).
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 🧠 TL;DR Summary
|
||||
|
||||
|Concept|Purpose|When Needed|
|
||||
|---|---|---|
|
||||
|**Unit of Work**|Organizes multiple EF operations into one commit|Always used with EF DbContext|
|
||||
|**Implicit transaction**|EF wraps each SaveChanges automatically|Ok for single SaveChanges calls|
|
||||
|**Explicit transaction**|Manually control commit/rollback across many operations|Needed when multiple SaveChanges, external services, concurrency, etc.|
|
||||
|**IsolationLevel.Serializable**|Highest locking level to prevent concurrent conflicts|Use for critical resource locks (e.g., seat booking)|
|
||||
|**Be careful**|Keep transactions short to avoid locks and deadlocks|Avoid long-running operations inside the transaction|
|
||||
|
||||
---
|
||||
|
||||
If you want, I can provide:
|
||||
|
||||
- **Code templates** for UoW with transaction management
|
||||
|
||||
- **Interceptor or decorator** to automate transaction handling in your services
|
||||
|
||||
- **Concurrency demos** to test seat reservation under load
|
||||
|
||||
|
||||
Just say the word!
|
||||
Absolutely! Let’s clarify both of your statements with authoritative sources:
|
||||
|
||||
---
|
||||
|
||||
## 1. If I haven't called `BeginTransaction` and call `SaveChanges`, will changes be saved?
|
||||
|
||||
✅ **Yes.** EF Core automatically wraps **every** `SaveChanges()` call in its **own transaction**. This means:
|
||||
|
||||
- If **all operations succeed**, the data is saved.
|
||||
- If **anything fails**, the entire set is **rolled back**, leaving the DB unchanged. citeturn0search0turn0search9
|
||||
|
||||
🔁 So even without explicitly starting a transaction, `SaveChanges()` ensures atomicity for that batch of changes.
|
||||
|
||||
---
|
||||
|
||||
## 2. If I have called `BeginTransaction`, and then call `SaveChanges`, do changes go to the DB immediately?
|
||||
|
||||
✅ **Yes, but context matters.** When you wrap operations in an explicit transaction:
|
||||
|
||||
- Each `SaveChanges()` writes within that transaction.
|
||||
- However, **none** of these writes become visible to other connections until you call `Commit()`.
|
||||
|
||||
EF also uses **savepoints** internally (for multiple `SaveChanges()` calls) to enable partial rollbacks without rolling back the entire transaction. citeturn0search0turn0search2turn0search3
|
||||
|
||||
---
|
||||
|
||||
### 🧩 In Plain English
|
||||
|
||||
| Scenario | SaveChanges Only | BeginTransaction + SaveChanges |
|
||||
|------------------|--------------------------------------------------|----------------------------------------------------|
|
||||
| No transaction | Auto-wrapped in its own transaction–immediate. | — |
|
||||
| With explicit transaction | Writes are within the transaction, not yet committed. | ✔ |
|
||||
| Rollback before commit | Not possible. SaveChanges is final. | ✔ Rolls everything back. |
|
||||
|
||||
---
|
||||
|
||||
### ✅ TL;DR
|
||||
|
||||
- ✅ **Without `BeginTransaction`**: `SaveChanges()` is atomic per call—commits or rolls back automatically.
|
||||
- ✅ **With `BeginTransaction`**: `SaveChanges()` writes into the transaction, but the changes are **held until** you call `Commit()`; `Rollback()` discards them all.
|
||||
|
||||
---
|
||||
|
||||
Want some **ready-to-use code** to introduce `BeginTransaction()` into your UnitOfWork and services? Just let me know and I’ll supply a clean implementation!
|
||||
@@ -0,0 +1,512 @@
|
||||
# Session 9 - Ticket Reservation
|
||||
As this order is comming to an end, we are completing the functionality of application with the vital feature of reserving tickets
|
||||
|
||||
## Miscellaneous / Fixes
|
||||
|
||||
- [ ] Fix GUID generation and async handling in ticket creation. (Use `Guid.NewGuid()` instead of `new Guid()`)
|
||||
```csharp
|
||||
SerialNumber = Guid.NewGuid().ToString("N")
|
||||
```
|
||||
|
||||
- [ ] Make `SerialNumber`, `TicketOrderId`, `BaseAmount` publicly settable in DTOs if they're private.
|
||||
- [ ] Check `Transaction` and make sure `SerialNumber` is not a required property.
|
||||
|
||||
## Branching
|
||||
- [ ] Create the feature/ticket-reservation branch based on develop
|
||||
|
||||
# Ticket Ordering System
|
||||
|
||||
## 🧱 Domain and Infrastructure Setup
|
||||
|
||||
- [ ] Check out new ERD: [Here](https://github.com/TheOrderOfPhoenix/ASP.NET/blob/main/ProjectOrientedSessions/docs/AlibabaERD-Version02.pdf)
|
||||
- [ ] Add migrations for the above database changes.
|
||||
|
||||
## 🧑💼 Path to Service Layer
|
||||
|
||||
- [ ] Create DTOs `CreateTravellerTicketDto` and `CreateTicketOrderDto` and the **mappings**:
|
||||
```csharp
|
||||
public class CreateTravellerTicketDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public long CreatorId { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "First name is required")]
|
||||
public required string FirstName { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Last name is required")]
|
||||
public required string LastName { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Id number is required")]
|
||||
[RegularExpression(@"^\d{10}$", ErrorMessage = "National ID number must be exactly 10 digits")]
|
||||
public required string IdNumber { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Gender should be identified")]
|
||||
public required short GenderId { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Phone number is required")]
|
||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
||||
public required string PhoneNumber { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Birth date is required")]
|
||||
public DateTime BirthDate { get; set; }
|
||||
|
||||
public long? SeatId { get; set; }
|
||||
public bool IsVIP { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
```
|
||||
```csharp
|
||||
public class CreateTicketOrderDto
|
||||
{
|
||||
public long TransportationId { get; set; }
|
||||
public List<CreateTravellerTicketDto> MyProperty { get; set; }
|
||||
}
|
||||
```
|
||||
```csharp
|
||||
|
||||
```
|
||||
Note: If you have the **Coupon** feature in your project, then add a `CouponCode` property in `CreateTicketOrderDto`.
|
||||
|
||||
- [ ] Modify SeatRepository to add method `GetSeatsByVehicleIdAsync`
|
||||
```csharp
|
||||
public Task<List<Seat>> GetSeatsByVehicleIdAsync(long vehicleId)
|
||||
{
|
||||
var seats = DbSet
|
||||
.Include(s => s.Vehicle)
|
||||
.Include(s => s.Tickets).ThenInclude(t => t.Traveler)
|
||||
.Where(s => s.VehicleId == vehicleId).ToListAsync();
|
||||
return seats;
|
||||
}
|
||||
```
|
||||
- [ ] Add Enum for **TicketStatus**, **VehicleType** and **TransactionType**
|
||||
```csharp
|
||||
public enum TicketStatusEnum
|
||||
{
|
||||
Reserved = 1,
|
||||
Paid = 2,
|
||||
CancelledByUser = 3,
|
||||
CancelledBySystem = 4,
|
||||
Used = 5,
|
||||
Expired = 6
|
||||
}
|
||||
```
|
||||
```csharp
|
||||
public enum VehicleTypeEnum
|
||||
{
|
||||
Airplane = 1,
|
||||
Train = 2,
|
||||
Bus = 3
|
||||
}
|
||||
```
|
||||
```csharp
|
||||
public enum TransactionTypeEnum
|
||||
{
|
||||
Deposit = 1,
|
||||
Withdraw = 2
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add the lock-service to lock the transportation through reservation, then register it:
|
||||
```csharp
|
||||
public class TransportationLockService : ITransportationLockService
|
||||
{
|
||||
private readonly ConcurrentDictionary<long, SemaphoreSlim> _locks = new();
|
||||
|
||||
public async Task<IDisposable> AcquireLockAsync(long transportationId)
|
||||
{
|
||||
var semaphore = _locks.GetOrAdd(transportationId, new SemaphoreSlim(1, 1));
|
||||
await semaphore.WaitAsync();
|
||||
return new Releaser(() => semaphore.Release());
|
||||
}
|
||||
|
||||
private class Releaser : IDisposable
|
||||
{
|
||||
private readonly Action _release;
|
||||
|
||||
public Releaser(Action release)
|
||||
{
|
||||
_release = release;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_release();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add method `CreateAsync` in `TransportationService`
|
||||
```csharp
|
||||
public async Task<Result<long>> CreateAsync(long accountId, TransactionDto dto)
|
||||
{
|
||||
Transaction transaction = new();
|
||||
_mapper.Map(dto, transaction);
|
||||
transaction.AccountId = accountId;
|
||||
|
||||
await _transactionRepository.InsertAsync(transaction);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Result<long>.Success(transaction.Id);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add method `PayForTicketOrderAsync` in `AccountService`
|
||||
```csharp
|
||||
public async Task<Result<long>> PayForTicketOrderAsync(long accountId, long ticketOrderId, decimal baseAmount, decimal finalAmount)
|
||||
{
|
||||
var account = await _accountRepository.GetByIdAsync(accountId);
|
||||
if (account == null)
|
||||
{
|
||||
return Result<long>.Error(0, "Account not found");
|
||||
}
|
||||
|
||||
if (account.Balance < finalAmount)
|
||||
{
|
||||
return Result<long>.Error(0, "Not enough money");
|
||||
}
|
||||
|
||||
account.Withdraw(finalAmount);
|
||||
_accountRepository.Update(account);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TransactionDto dto = new()
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Description = "Payment for ticket order #" + ticketOrderId + " at " + DateTime.UtcNow,
|
||||
BaseAmount = baseAmount,
|
||||
FinalAmount = finalAmount,
|
||||
SerialNumber = Guid.NewGuid().ToString("N"),
|
||||
TicketOrderId = ticketOrderId,
|
||||
TransactionTypeId = (int)TransactionTypeEnum.Withdraw,
|
||||
TransactionType = TransactionTypeEnum.Withdraw.ToString()
|
||||
};
|
||||
|
||||
return await _transactionService.CreateAsync(accountId, dto);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Create `ITicketOrderService`, `TicketOrderService` and implement `CreateTicketOrderAsync`
|
||||
```csharp
|
||||
public async Task<Result<long>> CreateTicketOrderAsync(long accountId, CreateTicketOrderDto dto)
|
||||
{
|
||||
// get the account
|
||||
var account = await _accountRepository.GetByIdAsync(accountId);
|
||||
if (account == null)
|
||||
{
|
||||
return Result<long>.Error(0, "Account not found");
|
||||
}
|
||||
|
||||
// get the transportation
|
||||
var transportation = await _transportationRepository.GetByIdAsync(dto.TransportationId);
|
||||
if (transportation == null)
|
||||
{
|
||||
return Result<long>.Error(0, "Transportation not found");
|
||||
}
|
||||
|
||||
// lock the transportation through reservation
|
||||
using (await _transportationLockService.AcquireLockAsync(dto.TransportationId))
|
||||
{
|
||||
var baseAmount = transportation.BasePrice * dto.Travellers.Count;
|
||||
if (account.Balance < baseAmount)
|
||||
{
|
||||
return Result<long>.Error(0, "Not enough money");
|
||||
}
|
||||
// check validity of transportation
|
||||
var checkSeats = ValidateTransportationAndSeats(transportation, dto.Travellers);
|
||||
if (!string.IsNullOrEmpty(checkSeats))
|
||||
{
|
||||
return Result<long>.Error(0, checkSeats);
|
||||
}
|
||||
|
||||
var finalAmount = baseAmount;
|
||||
await AssignSeatsIfDynamic(transportation.VehicleId, dto.Travellers);
|
||||
await UpsertTravellers(accountId, dto.Travellers);
|
||||
|
||||
// add the ticket order by the info we have
|
||||
TicketOrder ticketOrder = new()
|
||||
{
|
||||
BuyerId = accountId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Description = "",
|
||||
SerialNumber = Guid.NewGuid().ToString("N"),
|
||||
TransportationId = dto.TransportationId
|
||||
};
|
||||
await _ticketOrderRepository.InsertAsync(ticketOrder);
|
||||
|
||||
foreach (var traveller in dto.Travellers)
|
||||
{
|
||||
if (!traveller.SeatId.HasValue)
|
||||
{
|
||||
return Result<long>.Error(0, "Seat ID is required for each traveller");
|
||||
}
|
||||
|
||||
Ticket ticket = new()
|
||||
{
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Description = traveller.Description,
|
||||
SeatId = traveller.SeatId.Value,
|
||||
SerialNumber = Guid.NewGuid().ToString("N"),
|
||||
TicketOrder = ticketOrder,
|
||||
TicketStatusId = 1,
|
||||
TravelerId = traveller.Id,
|
||||
};
|
||||
await _ticketRepository.InsertAsync(ticket);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
await _accountService.PayForTicketOrderAsync(account.Id, ticketOrder.Id,
|
||||
baseAmount, finalAmount);
|
||||
return Result<long>.Success(ticketOrder.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
- [ ] Register `TicketOrderService` in DI container.
|
||||
|
||||
## 🎯 Controller Layer
|
||||
|
||||
- [ ] Create `TicketOrderController` with the endpoint `POST /CreateTicketOrder`
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Roles = "User")]
|
||||
public class TicketOrderController : ControllerBase
|
||||
{
|
||||
private readonly IUserContext _userContext;
|
||||
private readonly ITicketOrderService _ticketOrderService;
|
||||
|
||||
public TicketOrderController(IUserContext userContext,
|
||||
ITicketOrderService ticketOrderService)
|
||||
{
|
||||
_userContext = userContext;
|
||||
_ticketOrderService = ticketOrderService;
|
||||
}
|
||||
|
||||
[HttpPost("create-order")]
|
||||
public async Task<IActionResult> CreateTicketOrder([FromBody] CreateTicketOrderDto dto)
|
||||
{
|
||||
long accountId = _userContext.GetUserId();
|
||||
// check for account-id to be valid
|
||||
if (accountId <= 0)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _ticketOrderService.CreateTicketOrderAsync(accountId, dto);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
return Ok(result.Data);
|
||||
}
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
ResultStatus.Unauthorized => Unauthorized(result.ErrorMessage),
|
||||
ResultStatus.NotFound => NotFound(result.ErrorMessage),
|
||||
ResultStatus.ValidationError => BadRequest(result.ErrorMessage),
|
||||
_ => StatusCode(500, result.ErrorMessage)
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🗃️ Repository Layer
|
||||
|
||||
Implement there methods in `TicketOrderRepository` and its interface
|
||||
- [ ] `FindAndLoadAllDetails`
|
||||
```csharp
|
||||
public Task<TicketOrder?> FindAndLoadAllDetailsAsync(long id)
|
||||
{
|
||||
var ticketOrder = DbSet
|
||||
.Include(to => to.Transportation).ThenInclude(t => t.FromLocation).ThenInclude(fl => fl.City)
|
||||
.Include(to => to.Transportation).ThenInclude(t => t.ToLocation).ThenInclude(tl => tl.City)
|
||||
.Include(to => to.Tickets).ThenInclude(t => t.Traveler)
|
||||
.Where(to => to.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
return ticketOrder;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] `GetAllByBuyerId`
|
||||
```csharp
|
||||
public async Task<List<TicketOrder>> GetAllByBuyerId(long buyerId)
|
||||
{
|
||||
var ticketOrders = await DbSet
|
||||
.Include(to => to.Transaction)
|
||||
.Include(to => to.Transportation).ThenInclude(t => t.FromLocation)
|
||||
.Include(to => to.Transportation).ThenInclude(t => t.ToLocation)
|
||||
.Include(to => to.Transportation).ThenInclude(t => t.Company)
|
||||
.Include(to => to.Transportation).ThenInclude(t => t.Vehicle)
|
||||
.Where(to => to.BuyerId == buyerId).ToListAsync();
|
||||
return ticketOrders;
|
||||
}
|
||||
```
|
||||
|
||||
# Transportation and Seat Selection
|
||||
|
||||
- [ ] Add `TransportationSeatDto`.
|
||||
```csharp
|
||||
public class TransportationSeatDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public int Row { get; set; }
|
||||
public int Column { get; set; }
|
||||
public bool IsVIP { get; set; }
|
||||
public bool IsAvailable { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool IsReserved { get; set; }
|
||||
public short? GenderId { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add mapping from `Seat` to `TransportationSeatDto`.
|
||||
```csharp
|
||||
CreateMap<Seat, TransportationSeatDto>()
|
||||
.ForMember(dest => dest.IsReserved, opt => opt.MapFrom(src => src.Tickets.Any(t => t.TicketStatusId == (int)TicketStatusEnum.Reserved)))
|
||||
.ForMember(dest => dest.GenderId, opt => opt.MapFrom(src => src.Tickets.Any(t => t.TicketStatusId == (int)TicketStatusEnum.Reserved) ?
|
||||
src.Tickets.First(t => t.TicketStatusId == (int)TicketStatusEnum.Reserved).Traveler.GenderId : (short?)null));
|
||||
```
|
||||
|
||||
- [ ] Add method `GetSeatsByVehicleId` in `ISeatRepository` and implement it.
|
||||
```csharp
|
||||
public Task<List<Seat>> GetSeatsByVehicleIdAsync(long vehicleId)
|
||||
{
|
||||
var seats = DbSet
|
||||
.Include(s => s.Vehicle)
|
||||
.Include(s => s.Tickets).ThenInclude(t => t.Traveler)
|
||||
.Where(s => s.VehicleId == vehicleId).ToListAsync();
|
||||
return seats;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add `GetTransportationSeatsAsync` in `ITransportationService` and implement.
|
||||
```csharp
|
||||
public async Task<Result<List<TransportationSeatDto>>> GetTransportationSeatsAsync(long transportationId)
|
||||
{
|
||||
var transportation = await _transportationRepository.GetByIdAsync(transportationId);
|
||||
if (transportation == null)
|
||||
{
|
||||
return Result<List<TransportationSeatDto>>.Error(null, "Transportation not found");
|
||||
}
|
||||
|
||||
var seats = await _seatRepository.GetSeatsByVehicleIdAsync(transportation.VehicleId);
|
||||
if (seats == null || seats.Count != 0)
|
||||
{
|
||||
return Result<List<TransportationSeatDto>>.Success(_mapper.Map<List<TransportationSeatDto>>(seats));
|
||||
}
|
||||
|
||||
return Result<List<TransportationSeatDto>>.NotFound(null);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add `GetTransportationSeats` endpoint in `TransportationController`.
|
||||
```csharp
|
||||
[HttpGet("{transportationId}/seats")]
|
||||
public async Task<IActionResult> GetTransportationSeats(long transportationId)
|
||||
{
|
||||
var result = await _transportationService.GetTransportationSeatsAsync(transportationId);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
return Ok(result.Data);
|
||||
}
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
ResultStatus.Unauthorized => Unauthorized(result.ErrorMessage),
|
||||
ResultStatus.NotFound => NotFound(result.ErrorMessage),
|
||||
ResultStatus.ValidationError => BadRequest(result.ErrorMessage),
|
||||
_ => StatusCode(500, result.ErrorMessage)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Ensure `RemainingCapacity` is treated as calculated (ignored in EF, removed from schema).
|
||||
```csharp
|
||||
public int RemainingCapacity => Vehicle.Capacity -
|
||||
TicketOrders?.SelectMany(to => to.Tickets)
|
||||
.Count(t => t.TicketStatusId == 1) ?? 0;
|
||||
```
|
||||
|
||||
### ✅ Ticket Review & Confirmation
|
||||
|
||||
- [ ] Make sure you have `TravlerTicketDto`, mapped to `` with the details
|
||||
```csharp
|
||||
public class TravellerTicketDto
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public required string SerialNumber { get; set; }
|
||||
public required string TravellerName { get; set; }
|
||||
public DateTime BirthDate { get; set; }
|
||||
public required string SeatNumber { get; set; }
|
||||
public required string TicketStatus { get; set; }
|
||||
public string? CompanionName { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
```
|
||||
```csharp
|
||||
CreateMap<Ticket, TravellerTicketDto>()
|
||||
.ForMember(dest => dest.Id, opt => opt.MapFrom(src => src.Id))
|
||||
.ForMember(dest => dest.TravellerName, opt => opt.MapFrom(src => src.Traveler != null ? $"{src.Traveler.FirstName} {src.Traveler.LastName}" : ""))
|
||||
.ForMember(dest => dest.SerialNumber, opt => opt.MapFrom(src => src.SerialNumber))
|
||||
.ForMember(dest => dest.TicketStatus, opt => opt.MapFrom(src => src.TicketStatus.Ttile))
|
||||
.ForMember(dest => dest.CompanionName, opt => opt.MapFrom(src => src.Companion != null ? $"{src.Companion.FirstName} {src.Companion.LastName}" : ""))
|
||||
.ForMember(dest => dest.Description, opt => opt.MapFrom(src => src.Description));
|
||||
```
|
||||
|
||||
- [ ] Implement `GetTicketOrderDetails` endpoint to fetch ticket summary, make sure to create required methods as well
|
||||
```csharp
|
||||
[HttpGet("my-travels/{ticketOrderId}")]
|
||||
public async Task<IActionResult> GetTravelDetails(long ticketOrderId)
|
||||
{
|
||||
long accountId = _userContext.GetUserId();
|
||||
if (accountId <= 0)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var result = await _accountService.GetTicketOrderDetailsAsync(accountId, ticketOrderId);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
return Ok(result.Data);
|
||||
}
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
ResultStatus.Unauthorized => Unauthorized(result.ErrorMessage),
|
||||
ResultStatus.NotFound => NotFound(result.ErrorMessage),
|
||||
ResultStatus.ValidationError => BadRequest(result.ErrorMessage),
|
||||
_ => StatusCode(500, result.ErrorMessage)
|
||||
};
|
||||
}
|
||||
```
|
||||
```csharp
|
||||
public async Task<Result<List<TravellerTicketDto>>> GetTicketOrderDetailsAsync(long accoundId, long ticketOrderid)
|
||||
{
|
||||
var result = await _ticketRepository.GetTicketsByTicketOrderId(ticketOrderid);
|
||||
if (result != null)
|
||||
{
|
||||
if (result.Count > 0 && result.First().TicketOrder.BuyerId != accoundId)
|
||||
{
|
||||
return Result<List<TravellerTicketDto>>.Error(null, "Account unauthorized");
|
||||
}
|
||||
|
||||
return Result<List<TravellerTicketDto>>.Success(_mapper.Map<List<TravellerTicketDto>>(result));
|
||||
}
|
||||
|
||||
return Result<List<TravellerTicketDto>>.NotFound(null);
|
||||
}
|
||||
```
|
||||
```csharp
|
||||
public async Task<List<Ticket>> GetTicketsByTicketOrderId(long ticketOrderId)
|
||||
{
|
||||
var tickets = await DbSet
|
||||
.Include(t => t.Traveler)
|
||||
.Include(t => t.TicketStatus)
|
||||
.Include(t => t.Companion)
|
||||
.Include(t => t.Seat)
|
||||
.Include(t => t.TicketOrder)
|
||||
.Where(t => t.TicketOrderId == ticketOrderId).ToListAsync();
|
||||
return tickets;
|
||||
}
|
||||
```
|
||||
|
||||
## Merge
|
||||
- [ ] Create a PR and merge the current branch with develop
|
||||
@@ -0,0 +1,270 @@
|
||||
# Reservation System Development Guide - Session 10
|
||||
|
||||
This guide helps developers understand how to implement, update, and maintain the ticket reservation system. It summarizes changes from commits and provides step-by-step actions, grouped by feature area.
|
||||
https://github.com/MehrdadShirvani/AlibabaClone-Frontend/commits/develop/
|
||||
|
||||
## Fixes and Missing Things from Session 08
|
||||
### Authentication & Protected Routes
|
||||
|
||||
- [ ] Add session persistence in `authStore` to store token more persistently
|
||||
```ts
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { AuthResponseDto } from "@/shared/models/authentication/AuthResponseDto";
|
||||
|
||||
interface AuthState {
|
||||
user: AuthResponseDto | null;
|
||||
login: (user: AuthResponseDto) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
user: null,
|
||||
login: (user: AuthResponseDto) => set({ user }),
|
||||
logout: () => set({ user: null }),
|
||||
}),
|
||||
{
|
||||
name: "auth-storage", // storage key in localStorage
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
## Profile and User Info Enhancements
|
||||
|
||||
- [ ] Fix and align `birthDate` types in `PersonDto`, `TransportationSearchResult`, and `ListOfTravelers`. Make sure to use `Date | string` data type for them
|
||||
|
||||
## Branching
|
||||
|
||||
- [ ] Create the feature/themes branch based on develop
|
||||
|
||||
### 🎨 Theming and UI Styling
|
||||
|
||||
- [ ] Install and import `preline`
|
||||
```bash
|
||||
npm install preline
|
||||
```
|
||||
- [ ] Add theme colors and global styles (index.css). Take a look at their [Documentation](https://preline.co/docs/index.html)
|
||||
- [ ] Add `ThemeSwitcher` and integrate it into the navbar
|
||||
- [ ] If you decide to do this part after implementing pages, make sure to add theme support to the following components:
|
||||
- [ ] `transportationCard`, `transportationSearchForm`, `ReviewAndConfirm`
|
||||
- [ ] All modals: `LoginModal`, `RegisterModal`, `SelectFromPeopleModal`
|
||||
- [ ] Profile section: `ProfilePage`, `ProfileSummary`, `PersonalInformation`, `AccountInfo`, `PersonalAccountInfo`, `BankAccountDetails`, `MyTravels`, `MyTransactions`, `ListOfTravelers`
|
||||
- [ ] Reservation views and components
|
||||
|
||||
### Merge
|
||||
- [ ] Create a PR and merge the current branch with develop
|
||||
|
||||
## Branching for reservation
|
||||
|
||||
- [ ] Create the feature/ticket-reservation branch based on develop
|
||||
|
||||
## Backend Agent Methods
|
||||
|
||||
- [ ] Add `CreateTicketOrderDto` and `CreateTravelerTicketDto`. Check backend endpoints for the dtos you need for this session
|
||||
- [ ] Add `TransportationSeatDto`
|
||||
- [ ] Add related methods in `TicketOrder` and add it to agent
|
||||
```ts
|
||||
const TicketOrder = {
|
||||
create: (data : CreateTicketOrderDto) => request.post<number>('/ticketOrder/create', data),
|
||||
downloadPdf: (ticketOrderId : number) => request.get<Blob>(`/ticketOrder/${ticketOrderId}/pdf`, {
|
||||
responseType: 'blob',
|
||||
}),
|
||||
}
|
||||
```
|
||||
|
||||
## Reservation Process & Step Management
|
||||
|
||||
- [ ] Implement `useReservationStore` using `zustand` to manage reservation state
|
||||
```ts
|
||||
import { PersonDto } from '@/shared/models/account/PersonDto';
|
||||
import { CreateTravelerTicketDto } from '@/shared/models/ticketOrder/createTravelerTicketDto';
|
||||
import { TransportationSearchResult } from '@/shared/models/transportation/transportationSearchResult';
|
||||
import { create } from 'zustand';
|
||||
|
||||
const defaultTraveler: CreateTravelerTicketDto = {
|
||||
id: 0,
|
||||
creatorAccountId: 0,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
genderId: 0,
|
||||
birthDate: '',
|
||||
idNumber: '',
|
||||
phoneNumber: '',
|
||||
englishFirstName: '',
|
||||
englishLastName: '',
|
||||
isVIP: false,
|
||||
seatId: null,
|
||||
description: '',
|
||||
};
|
||||
|
||||
type ReservationStep = "transportation" | "travelers" | "review" | "payment" | "success";
|
||||
|
||||
interface ReservationState {
|
||||
transportation: TransportationSearchResult | null;
|
||||
travelers: CreateTravelerTicketDto[];
|
||||
ticketOrderId: number;
|
||||
couponCode: string | null;
|
||||
paymentInfo: any;
|
||||
people: PersonDto[],
|
||||
currentStep: ReservationStep,
|
||||
isConfirmed: boolean,
|
||||
isPayed: boolean,
|
||||
isTravelerPartDone: boolean,
|
||||
addTraveler: (traveler: CreateTravelerTicketDto) => void;
|
||||
setTravelers: (travelers: CreateTravelerTicketDto[]) => void;
|
||||
updateTraveler: (index: number, traveler: CreateTravelerTicketDto) => void;
|
||||
removeTraveler: (index: number) => void;
|
||||
setTicketOrderId: (newId: number) => void;
|
||||
setTransportation: (transportation: TransportationSearchResult) => void;
|
||||
setCouponCode: (code: string | null) => void;
|
||||
setPaymentInfo: (info: any) => void;
|
||||
setPeople: (people: PersonDto[]) => void;
|
||||
|
||||
setCurrentStep: (step: ReservationStep) => void;
|
||||
setIsConfirmed: (value: boolean) => void;
|
||||
setIsPayed: (value: boolean) => void;
|
||||
setIsTravelerPartDone: (value: boolean) => void;
|
||||
|
||||
resetReservation: () => void;
|
||||
}
|
||||
|
||||
export const useReservationStore = create<ReservationState>((set) => ({
|
||||
transportation: null,
|
||||
people: [],
|
||||
travelers: [defaultTraveler],
|
||||
couponCode: null,
|
||||
paymentInfo: null,
|
||||
ticketOrderId : 0,
|
||||
currentStep: "transportation",
|
||||
isConfirmed: false,
|
||||
isPayed : false,
|
||||
isTravelerPartDone : false,
|
||||
setTicketOrderId: (newId) => set(() =>({
|
||||
ticketOrderId : newId
|
||||
})),
|
||||
addTraveler: (traveler) => set((state) => ({ travelers: [...state.travelers, traveler] })),
|
||||
setTravelers: (newTravelers) => set(() => ({
|
||||
travelers : newTravelers
|
||||
})),
|
||||
setPeople: (newPeople) => set(() => ({
|
||||
people : newPeople
|
||||
})),
|
||||
updateTraveler: (index, traveler) =>
|
||||
set((state) => {
|
||||
const updated = [...state.travelers];
|
||||
updated[index] = traveler;
|
||||
return { travelers: updated };
|
||||
}),
|
||||
|
||||
removeTraveler: (index) =>
|
||||
set((state) => {
|
||||
if (state.travelers.length <= 1) return state;
|
||||
const updated = [...state.travelers];
|
||||
updated.splice(index, 1);
|
||||
return { travelers: updated };
|
||||
}),
|
||||
|
||||
setTransportation: (theTransportation) => set({ transportation: theTransportation }),
|
||||
setCouponCode: (code) => set({ couponCode: code }),
|
||||
setPaymentInfo: (info) => set({ paymentInfo: info }),
|
||||
setCurrentStep: (step) => set({ currentStep: step }),
|
||||
|
||||
resetReservation: () =>
|
||||
set({
|
||||
transportation: null,
|
||||
travelers: [],
|
||||
ticketOrderId: 0,
|
||||
couponCode: null,
|
||||
paymentInfo: null,
|
||||
people: [],
|
||||
currentStep: "transportation",
|
||||
isConfirmed: false,
|
||||
isPayed: false,
|
||||
isTravelerPartDone: false
|
||||
}),
|
||||
|
||||
setIsConfirmed: (value) => set(() => ({
|
||||
isConfirmed : value
|
||||
})),
|
||||
setIsPayed: (value) => set(() => ({
|
||||
isPayed : value
|
||||
})),
|
||||
setIsTravelerPartDone: (value) => set(() => ({
|
||||
isTravelerPartDone : value
|
||||
})),
|
||||
}));
|
||||
```
|
||||
|
||||
- [ ] Create step-based routing using `ReservationLayout`. Step-based routing, in the context of contact centers or workflow management, refers to a method of directing work items (like customer service calls or support tickets) to the most appropriate agent or resource based on a series of predefined steps or conditions.
|
||||
|
||||
- [ ] Add routing for reservation steps in `App.tsx`
|
||||
|
||||
- [ ] Add `StepIndicator` component to show step progress visually (optional)
|
||||
- [ ] Add `stepGuard` logic to prevent accessing future steps prematurely
|
||||
- [ ] Add logic to skip back only if previous steps are completed
|
||||
|
||||
- [ ] Create `TravelerForm` to gather passenger info, with the possibility to load data from the related people of the account.
|
||||
|
||||
- [ ] Create `TravelerDetailsForm` to gather passengers info with `TravelerForm` integration
|
||||
|
||||
- [ ] Create `ReviewAndConfirm` page to review selections
|
||||
|
||||
- [ ] Create `PaymentForm` for transaction process
|
||||
|
||||
- [ ] Create `TicketIssued` page for confirmation
|
||||
|
||||
- [ ] Add validation to show error if `seatId` is missing
|
||||
|
||||
## Seat Selection
|
||||
- [ ] Add DTO:
|
||||
```tsx
|
||||
export interface transportationSeatDto{
|
||||
id : number,
|
||||
vehicleId : number,
|
||||
row : number,
|
||||
column : number,
|
||||
isVIP : boolean,
|
||||
isAvailable : boolean,
|
||||
description : string | null,
|
||||
isReserved : boolean,
|
||||
genderId : number | null
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Add `getSeats()` method to `agent.ts`
|
||||
- [ ] Add `SeatGridSelector` component for graphical seat layout in `TravelerDetailForm` and integrate it with traveler list, only for Buses
|
||||
- [ ] Modify `TransportationCard` to integrate with seat selection
|
||||
- [ ] Add `SeatOnlyGridSeatMap` for simpler seat-only display
|
||||
|
||||
### Coupon Integration
|
||||
|
||||
- [ ] Add `CouponValidationRequestDto` and `DiscountDto`
|
||||
- [ ] Add `validateCoupon()` method to `agent.ts`
|
||||
- [ ] Update `useReservationStore` to include `CouponCode`
|
||||
- [ ] Ensure `CreateTicketOrderDto` uses `CouponCode` instead of `couponId`
|
||||
- [ ] Connect coupon validation flow in `ReviewAndConfirm`
|
||||
|
||||
## Search, Filter, and Sort Functionality
|
||||
|
||||
- [ ] Add filters (company) and sorting (time or price) UI to `SearchResultPage`
|
||||
- [ ] Add company logo support in result cards and filters
|
||||
- [ ] Add `previous/next day` buttons for time navigation
|
||||
- [ ] Add remaining capacity check to transportation cards
|
||||
- [ ] Add refund policy info display
|
||||
- [ ] Implement showing seat map in `TransportaionCard` using `ReadOnlySeatMap`
|
||||
|
||||
### 📎 Notes
|
||||
|
||||
- Ensure you run a full theme test after UI changes.
|
||||
- Test step transitions with various invalid scenarios.
|
||||
- Confirm persistent session and coupon behavior across refreshes.
|
||||
- Validate all filters, sort, and search navigation works.
|
||||
- Test seat selection and proper rendering of rotated layouts.
|
||||
|
||||
## Merge
|
||||
- [ ] Create a PR and merge the current branch with develop
|
||||
Reference in New Issue
Block a user