refactor: change folder structure

This commit is contained in:
2025-07-18 13:02:13 +03:30
parent 835f833927
commit 3c05411a29
1329 changed files with 116 additions and 38 deletions
@@ -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**
### 👇 Heres 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, youd 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 youre 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 Servers `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**
- Dont 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. Lets 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 dont interfere with each other|
|**Durability**|Once committed, data is persisted permanently|
---
## ✅ Why You Need Transactions
In your case, youre:
- 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|
|Dont 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 doesnt 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. Lets 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? Wouldnt `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 **dont 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**.
Heres 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 dont 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 dont 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! Lets 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. citeturn0search0turn0search9
🔁 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. citeturn0search0turn0search2turn0search3
---
### 🧩 In Plain English
| Scenario | SaveChanges Only | BeginTransaction + SaveChanges |
|------------------|--------------------------------------------------|----------------------------------------------------|
| No transaction | Auto-wrapped in its own transactionimmediate. | — |
| 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 Ill 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,111 @@
# Reservation System Development Guide
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
- [ ] Store `showLoginModal` state in `authStore` and adjust navbar
- [ ] Add `ProtectedRoute` component
- [ ] Adjust main `App` to route authenticated pages through protection layer
- [ ] Add session persistence in `authStore` to store token more persistently
---
## Profile and User Info Enhancements
- [ ] Fix and align `birthDate` types in `PersonDto`, `transportationSearchResult`, and `ListOfTravelers`
## Agent
- [ ] Add `topUpDto` and its method in `agent.ts`
- [ ] Add optional config parameter to `request()` in `agent.ts`
# Branching
- [ ] Create the feature/themes branch based on develop
# 🎨 Theming and UI Styling
- [ ] Install and import `preline`
- [ ] Add theme colors and global styles (index.css)
- [ ] 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
- [ ] Create the feature/ticket-reservation branch based on develop
# Backend Agent Methods
- [ ] Add `createTicketOrderDto` and `createTravelerTicketDto`
- [ ] Add `transportationSeatDto`
- [ ] Add related methods in `TicketOrder` and add it to agent
# Reservation Process & Step Management
- [ ] Implement `useReservationStore` using `zustand` to manage reservation state
- [ ] Create step-based routing using `ReservationLayout`
- [ ] Add routing for reservation steps in `App.tsx`
- [ ] Add `StepIndicator` component to show step progress visually
- [ ] 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