Skip to content

Commit d5ae36e

Browse files
committed
feat: Enhance sales management with new scheduling and editing dialogs, improved SSE handling, and updated query invalidation rules
1 parent e0cb58e commit d5ae36e

13 files changed

Lines changed: 541 additions & 43 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ dotnet test -- --treenode-filter "/*/*/*/*[Category=Integration]"
9595
## Common Mistakes
9696

9797
- Business logic in endpoints -> put it in aggregates/handlers
98-
- Missing SSE notification -> add to `MartenCommitListener`
98+
- Missing SSE notification -> add handler in `ProjectionCommitListener` (API) AND add query key in `QueryInvalidationService` (Web)
9999
- Missing cache invalidation -> call `RemoveByTagAsync` after mutations
100100

101101
## Quick Troubleshooting

src/BookStore.ApiService/AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@
2828

2929
## Common Mistakes
3030
- ❌ Business logic in endpoints → Put logic in aggregates/handlers
31-
- ❌ Missing SSE notification → Add to `MartenCommitListener`
31+
- ❌ Missing SSE notification → Add handler in `ProjectionCommitListener`
3232
- ❌ Missing cache invalidation → Call `RemoveByTagAsync` after mutations
3333
- ❌ Manually running Marten async daemon → Async projections are updated by Wolverine
3434
- ❌ Skipping tenant context → Use tenant-scoped sessions and cache keys
3535
- ❌ Ignoring ETag checks → Use `IHaveETag` and `ETagHelper`
3636
- ❌ Returning plain JSON errors → ALL failures must return ProblemDetails with error codes (endpoints, handlers, middleware)
37+
-`DateTimeOffset` precision in aggregates → URL params lose sub-second precision; compare stored values with `TruncateToSeconds` in `Apply()` methods, and store the canonical `sale.Start` from aggregate state (not the incoming URL parameter) in events
3738

3839
## Project Layout
3940
| Path | Purpose |

src/BookStore.ApiService/Aggregates/BookAggregate.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ void Apply(BookSaleScheduled @event)
7777
Sales.Add(@event.Sale);
7878
}
7979

80-
void Apply(BookSaleCancelled @event) => _ = Sales.RemoveAll(s => s.Start == @event.SaleStart);
80+
void Apply(BookSaleCancelled @event) => _ = Sales.RemoveAll(s => TruncateToSeconds(s.Start) == TruncateToSeconds(@event.SaleStart));
8181

8282
void Apply(BookDiscountUpdated @event) => CurrentDiscountPercentage = @event.DiscountPercentage;
8383

@@ -399,13 +399,13 @@ public Result<BookSaleCancelled> CancelSale(DateTimeOffset saleStart)
399399
return Result.Failure<BookSaleCancelled>(Error.Conflict(ErrorCodes.Books.AlreadyDeleted, "Cannot cancel sale for a deleted book"));
400400
}
401401

402-
var sale = Sales.FirstOrDefault(s => s.Start == saleStart);
402+
var sale = Sales.FirstOrDefault(s => TruncateToSeconds(s.Start) == TruncateToSeconds(saleStart));
403403
if (sale.Equals(default(BookSale)))
404404
{
405405
return Result.Failure<BookSaleCancelled>(Error.NotFound(ErrorCodes.Books.SaleNotFound, "No sale found with the specified start time"));
406406
}
407407

408-
return new BookSaleCancelled(Id, saleStart);
408+
return new BookSaleCancelled(Id, sale.Start);
409409
}
410410

411411
public Result<BookDiscountUpdated> ApplyDiscount(decimal percentage)
@@ -432,5 +432,8 @@ public Result<BookDiscountUpdated> RemoveDiscount()
432432

433433
return new BookDiscountUpdated(Id, 0);
434434
}
435+
436+
static DateTimeOffset TruncateToSeconds(DateTimeOffset dt)
437+
=> new(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset);
435438
}
436439

src/BookStore.ApiService/Aggregates/SaleAggregate.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ internal void Apply(BookSaleScheduled @event)
1818
ScheduledSales.Add(@event.Sale);
1919
}
2020

21-
internal void Apply(BookSaleCancelled @event) => _ = ScheduledSales.RemoveAll(s => s.Start == @event.SaleStart);
21+
internal void Apply(BookSaleCancelled @event)
22+
=> _ = ScheduledSales.RemoveAll(s => TruncateToSeconds(s.Start) == TruncateToSeconds(@event.SaleStart));
2223

2324
public Result<BookSaleScheduled> ScheduleSale(decimal percentage, DateTimeOffset start, DateTimeOffset end)
2425
{
@@ -44,12 +45,17 @@ public Result<BookSaleScheduled> ScheduleSale(decimal percentage, DateTimeOffset
4445

4546
public Result<BookSaleCancelled> CancelSale(DateTimeOffset saleStart)
4647
{
47-
var sale = ScheduledSales.FirstOrDefault(s => s.Start == saleStart);
48+
// Truncate to seconds to be resilient against sub-second precision loss during URL serialization
49+
var normalizedStart = TruncateToSeconds(saleStart);
50+
var sale = ScheduledSales.FirstOrDefault(s => TruncateToSeconds(s.Start) == normalizedStart);
4851
if (sale.Equals(default(BookSale)))
4952
{
5053
return Result.Failure<BookSaleCancelled>(Error.NotFound(ErrorCodes.Books.SaleNotFound, "No sale found with the specified start time"));
5154
}
5255

53-
return new BookSaleCancelled(Id, saleStart);
56+
return new BookSaleCancelled(Id, sale.Start);
5457
}
58+
59+
static DateTimeOffset TruncateToSeconds(DateTimeOffset dt)
60+
=> new(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset);
5561
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
using BookStore.ApiService.Infrastructure;
2+
using BookStore.ApiService.Projections;
3+
using BookStore.Shared.Models;
4+
using Marten;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.Extensions.Options;
7+
8+
namespace BookStore.ApiService.Endpoints;
9+
10+
public static class SalesEndpoints
11+
{
12+
public static RouteGroupBuilder MapSalesEndpoints(this RouteGroupBuilder group)
13+
{
14+
_ = group.MapGet("/", GetSales)
15+
.WithName("GetSales")
16+
.WithSummary("Get all scheduled book sales");
17+
18+
return group.RequireAuthorization("Admin");
19+
}
20+
21+
static async Task<IResult> GetSales(
22+
[FromServices] IQuerySession session,
23+
[FromServices] IOptions<PaginationOptions> paginationOptions,
24+
[AsParameters] PagedRequest request,
25+
CancellationToken cancellationToken)
26+
{
27+
var paging = request.Normalize(paginationOptions.Value);
28+
var now = DateTimeOffset.UtcNow;
29+
30+
var books = await session.Query<BookSearchProjection>()
31+
.Where(b => !b.Deleted)
32+
.ToListAsync(cancellationToken);
33+
34+
var allSales = books
35+
.Where(b => b.Sales.Count > 0)
36+
.SelectMany(b => b.Sales.Select(sale => new SaleDto
37+
{
38+
Id = b.Id,
39+
BookTitle = b.Title,
40+
BuyerName = string.Empty,
41+
Date = sale.Start,
42+
EndDate = sale.End,
43+
Amount = sale.Percentage,
44+
Status = now >= sale.Start && now < sale.End ? "Active"
45+
: now < sale.Start ? "Upcoming"
46+
: "Expired",
47+
ETag = ETagHelper.GenerateETag(b.Version)
48+
}))
49+
.OrderByDescending(s => s.Date)
50+
.ToList();
51+
52+
var totalCount = allSales.Count;
53+
var page = paging.Page!.Value;
54+
var pageSize = paging.PageSize!.Value;
55+
var items = allSales.Skip((page - 1) * pageSize).Take(pageSize).ToList();
56+
57+
return Results.Ok(new PagedListDto<SaleDto>(items, page, pageSize, totalCount));
58+
}
59+
}

src/BookStore.ApiService/Infrastructure/Extensions/EndpointMappingExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ static void MapPublicEndpoints(WebApplication app, Asp.Versioning.Builder.ApiVer
6565
.MapNotificationEndpoints()
6666
.WithTags("Notifications");
6767

68+
_ = publicApi.MapGroup("/sales")
69+
.MapSalesEndpoints()
70+
.WithTags("Sales");
71+
6872
// Shopping Cart endpoints
6973
app.MapShoppingCartEndpoints();
7074
}

src/BookStore.Client/BookStoreClientExtensions.cs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.Globalization;
2+
using System.Reflection;
13
using Microsoft.Extensions.DependencyInjection;
24
using Microsoft.Extensions.Logging;
35
using Refit;
@@ -9,6 +11,11 @@ namespace BookStore.Client;
911
/// </summary>
1012
public static class BookStoreClientExtensions
1113
{
14+
// Use ISO 8601 round-trip format for DateTimeOffset query params to preserve sub-second precision
15+
static readonly RefitSettings DefaultSettings = new()
16+
{
17+
UrlParameterFormatter = new Iso8601DateTimeOffsetFormatter()
18+
};
1219
/// <summary>
1320
/// Registers all BookStore API client interfaces with the service collection.
1421
/// </summary>
@@ -45,7 +52,7 @@ public static IServiceCollection AddBookStoreClient(
4552
// Helper to register client with standard configuration
4653
static IHttpClientBuilder AddClient<T>(this IServiceCollection services, Uri baseAddress, Action<IHttpClientBuilder>? configureClient = null) where T : class
4754
{
48-
var builder = services.AddRefitClient<T>()
55+
var builder = services.AddRefitClient<T>(DefaultSettings)
4956
.ConfigureHttpClient(c => c.BaseAddress = baseAddress)
5057
.AddHttpMessageHandler<BookStore.Client.Infrastructure.BookStoreHeaderHandler>()
5158
.AddHttpMessageHandler<BookStore.Client.Infrastructure.BookStoreErrorHandler>();
@@ -91,3 +98,15 @@ public static IServiceCollection AddBookStoreEvents(
9198
return services;
9299
}
93100
}
101+
102+
/// <summary>
103+
/// Formats <see cref="DateTimeOffset"/> query parameters using the ISO 8601 round-trip format ("O")
104+
/// to preserve sub-second precision through URL serialization.
105+
/// </summary>
106+
sealed class Iso8601DateTimeOffsetFormatter : DefaultUrlParameterFormatter
107+
{
108+
public override string? Format(object? parameterValue, ICustomAttributeProvider attributeProvider, Type type)
109+
=> parameterValue is DateTimeOffset dto
110+
? dto.ToString("O", CultureInfo.InvariantCulture)
111+
: base.Format(parameterValue, attributeProvider, type);
112+
}

src/BookStore.Shared/SaleDto.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ public record SaleDto
66
public string BookTitle { get; init; } = string.Empty;
77
public string BuyerName { get; init; } = string.Empty;
88
public DateTimeOffset Date { get; init; }
9+
public DateTimeOffset EndDate { get; init; }
910
public decimal Amount { get; init; }
1011
public string Status { get; init; } = string.Empty;
12+
public string? ETag { get; init; }
1113
}

src/BookStore.Web/AGENTS.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,19 @@
1010
✅ Use BookStore.Client Refit clients ❌ Call API endpoints directly
1111
✅ ReactiveQuery<T> for reads ❌ Manual data fetch without invalidation
1212
✅ QueryInvalidationService + SSE ❌ Polling for updates
13-
✅ OptimisticUpdateService for writes ❌ UI waits for server roundtrip
13+
✅ MudTable Items=@(_query?.Data) ❌ MudTable ServerData= on SSE-driven pages
14+
✅ _query?.MutateData for instant UI ❌ await API then reload (blocks UX)
15+
✅ _query.InvalidateAsync() after dialogs ❌ _table.ReloadServerData() after mutations
1416
✅ TenantService for tenant context ❌ Hardcoded tenant or missing headers
1517
```
1618

1719
## Common Mistakes
1820
- ❌ Calling HttpClient directly → Use injected BookStore.Client interfaces
19-
- ❌ Missing SSE invalidation mapping → Update QueryInvalidationService rules
21+
-`MudTable ServerData=` on SSE-driven admin pages → SSE-triggered reloads bypass `ServerData`; use `ReactiveQuery<IReadOnlyList<T>>` + `Items=@(_query?.Data)` instead
22+
- ❌ Missing SSE invalidation mapping → Update `QueryInvalidationService`; if the entity is stored inside a parent projection (e.g., sales inside `BookSearchProjection`), the parent notification must also yield the child query key
23+
- ❌ Forgetting rollback on failed mutations → Take a snapshot before `MutateData`, restore it in the catch block
24+
- ❌ Using `MutateData` for dialog-based mutations → Only use `MutateData` for inline single-step operations (cancel, delete); after dialogs use `_query.InvalidateAsync()` so the server response is the source of truth
25+
- ❌ Optimistic removal that leaves stale items after SSE → `MutateData` removes the row immediately; SSE then triggers a silent background `InvalidateAsync()` via `ReactiveQuery`, which replaces the local state; no extra reload needed
2026
- ❌ Business logic in .razor files → Move to Services/ or backing classes
2127
- ❌ Tenant mismatch in UI → Use TenantService and tenant-aware clients
2228

0 commit comments

Comments
 (0)