diff --git a/ConsoleApp1/AppDbContextConfiguration.cs b/ConsoleApp1/AppDbContextConfiguration.cs index dfe0b16..ae3a834 100644 --- a/ConsoleApp1/AppDbContextConfiguration.cs +++ b/ConsoleApp1/AppDbContextConfiguration.cs @@ -7,19 +7,19 @@ public class AppDbContext(DbContextOptions options) : DbContext(op { public DbSet Todos => Set(); - protected override void OnModelCreating(ModelBuilder modelBuilder) +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + // TodoItem mapping + modelBuilder.Entity(b => { - // TodoItem mapping - modelBuilder.Entity(b => - { - b.HasKey(x => x.Id); - b.Property(x => x.Title).IsRequired().HasMaxLength(200); - b.Property(x => x.Description).HasMaxLength(2000); - b.Property(x => x.CreatedAtUtc).HasDefaultValueSql("CURRENT_TIMESTAMP"); - b.Property(x => x.Priority).HasDefaultValue(TodoPriority.Medium); - b.Property(x => x.LabelsCsv).HasMaxLength(2000); - b.HasQueryFilter(x => !x.IsDeleted); - b.HasIndex(x => new { x.IsCompleted, x.IsDeleted, x.DueAtUtc }); - }); - } + b.HasKey(x => x.Id); + b.Property(x => x.Title).IsRequired().HasMaxLength(200); + b.Property(x => x.Description).HasMaxLength(2000); + b.Property(x => x.CreatedAtUtc).HasDefaultValueSql("CURRENT_TIMESTAMP"); + b.Property(x => x.Priority).HasDefaultValue(TodoPriority.Medium); + b.Property(x => x.LabelsCsv).HasMaxLength(2000); + b.HasQueryFilter(x => !x.IsDeleted); + b.HasIndex(x => new { x.IsCompleted, x.IsDeleted, x.DueAtUtc }); + }); +} } diff --git a/ConsoleApp1/ConsoleApp1.csproj b/ConsoleApp1/ConsoleApp1.csproj index aba0199..6fae550 100644 --- a/ConsoleApp1/ConsoleApp1.csproj +++ b/ConsoleApp1/ConsoleApp1.csproj @@ -12,7 +12,7 @@ - + diff --git a/ConsoleApp1/ErrorHandlingMiddleware.cs b/ConsoleApp1/ErrorHandlingMiddleware.cs index 5f465ab..9af8d01 100644 --- a/ConsoleApp1/ErrorHandlingMiddleware.cs +++ b/ConsoleApp1/ErrorHandlingMiddleware.cs @@ -7,16 +7,16 @@ namespace WebApi.Middleware public class ErrorHandlingMiddleware(RequestDelegate next, ILogger logger) { public async Task Invoke(HttpContext context) + { + try { - try - { - await next(context); - } - catch (ValidationException vex) - { - logger.LogWarning(vex, "Validation failed"); - context.Response.StatusCode = (int)HttpStatusCode.BadRequest; - context.Response.ContentType = "application/problem+json"; + await next(context); + } + catch (ValidationException vex) + { + logger.LogWarning(vex, "Validation failed"); + context.Response.StatusCode = (int)HttpStatusCode.BadRequest; + context.Response.ContentType = "application/problem+json"; var problem = new ValidationProblemDetails { @@ -29,26 +29,26 @@ public async Task Invoke(HttpContext context) problem.Errors[kv.Key] = kv.Value; } - await context.Response.WriteAsync(JsonSerializer.Serialize(problem)); - } - catch (Exception ex) - { - logger.LogError(ex, "Unhandled exception"); - context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; - context.Response.ContentType = "application/problem+json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(problem)); + } + catch (Exception ex) + { + logger.LogError(ex, "Unhandled exception"); + context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + context.Response.ContentType = "application/problem+json"; - var problem = new ProblemDetails - { - Title = "Internal Server Error", - Status = StatusCodes.Status500InternalServerError, - Detail = "An unexpected error occurred. Please try again later." - }; + var problem = new ProblemDetails + { + Title = "Internal Server Error", + Status = StatusCodes.Status500InternalServerError, + Detail = "An unexpected error occurred. Please try again later." + }; - await context.Response.WriteAsync(JsonSerializer.Serialize(problem)); - } + await context.Response.WriteAsync(JsonSerializer.Serialize(problem)); } } } +} namespace WebApi { diff --git a/ConsoleApp1/TodoDueReminderService.cs b/ConsoleApp1/TodoDueReminderService.cs index 0105e99..f799da0 100644 --- a/ConsoleApp1/TodoDueReminderService.cs +++ b/ConsoleApp1/TodoDueReminderService.cs @@ -9,26 +9,26 @@ public class TodoDueReminderService(ILogger logger, ITod : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) +{ + var timer = new PeriodicTimer(TimeSpan.FromMinutes(options.CurrentValue.IntervalMinutes <= 0 ? 1 : options.CurrentValue.IntervalMinutes)); + try { - var timer = new PeriodicTimer(TimeSpan.FromMinutes(options.CurrentValue.IntervalMinutes <= 0 ? 1 : options.CurrentValue.IntervalMinutes)); - try + while (await timer.WaitForNextTickAsync(stoppingToken)) { - while (await timer.WaitForNextTickAsync(stoppingToken)) + var overdue = await service.GetOverdueCountAsync(stoppingToken); + if (overdue > 0) { - var overdue = await service.GetOverdueCountAsync(stoppingToken); - if (overdue > 0) - { - logger.LogWarning("There are {Overdue} overdue todos.", overdue); - } - else - { - logger.LogInformation("No overdue todos."); - } + logger.LogWarning("There are {Overdue} overdue todos.", overdue); + } + else + { + logger.LogInformation("No overdue todos."); } } - catch (OperationCanceledException) - { - // normal on shutdown - } } + catch (OperationCanceledException) + { + // normal on shutdown + } +} } diff --git a/ConsoleApp1/TodoEndpointsConfiguration.cs b/ConsoleApp1/TodoEndpointsConfiguration.cs index 38a39dc..e1e1786 100644 --- a/ConsoleApp1/TodoEndpointsConfiguration.cs +++ b/ConsoleApp1/TodoEndpointsConfiguration.cs @@ -21,88 +21,88 @@ public static RouteGroupBuilder MapTodoEndpoints(this IEndpointRouteBuilder app) .WithTags("Todos"); // List with paging/filtering/sorting: /api/todos?page=1&pageSize=10&search=foo&sortBy=dueAtUtc&sortDir=desc&isCompleted=false - group.MapGet("", async (HttpContext http, AppDbContext db, int page = 1, int pageSize = 20, string? search = null, - string? sortBy = "createdAtUtc", string? sortDir = "desc", bool? isCompleted = null, string? label = null, string? priority = null, CancellationToken ct = default) => + group.MapGet("", async(HttpContext http, AppDbContext db, int page = 1, int pageSize = 20, string ? search = null, + string ? sortBy = "createdAtUtc", string ? sortDir = "desc", bool ? isCompleted = null, string ? label = null, string ? priority = null, CancellationToken ct = default) => { - page = Math.Max(1, page); - pageSize = Math.Clamp(pageSize, 1, 200); + page = Math.Max(1, page); + pageSize = Math.Clamp(pageSize, 1, 200); - var query = db.Todos.AsNoTracking().AsQueryable(); + var query = db.Todos.AsNoTracking().AsQueryable(); - if (!string.IsNullOrWhiteSpace(search)) - { - var like = $"%{search.Trim()}%"; - query = query.Where(t => - EF.Functions.Like(t.Title, like) || - (t.Description != null && EF.Functions.Like(t.Description, like))); - } + if (!string.IsNullOrWhiteSpace(search)) + { + var like = $"%{search.Trim()}%"; + query = query.Where(t => + EF.Functions.Like(t.Title, like) || + (t.Description != null && EF.Functions.Like(t.Description, like))); + } - if (isCompleted is not null) - { - query = query.Where(t => t.IsCompleted == isCompleted); - } + if (isCompleted is not null) + { + query = query.Where(t => t.IsCompleted == isCompleted); + } - if (!string.IsNullOrWhiteSpace(label)) - { - var l = label.Trim(); - query = query.Where(t => t.LabelsCsv != null && EF.Functions.Like(t.LabelsCsv, $"%{l}%")); - } + if (!string.IsNullOrWhiteSpace(label)) + { + var l = label.Trim(); + query = query.Where(t => t.LabelsCsv != null && EF.Functions.Like(t.LabelsCsv, $"%{l}%")); + } - if (!string.IsNullOrWhiteSpace(priority) && Enum.TryParse(priority, true, out var pr)) - { - query = query.Where(t => t.Priority == pr); - } + if (!string.IsNullOrWhiteSpace(priority) && Enum.TryParse(priority, true, out var pr)) + { + query = query.Where(t => t.Priority == pr); + } - // Sorting - query = (sortBy?.ToLowerInvariant(), sortDir?.ToLowerInvariant()) switch - { - ("title", "asc") => query.OrderBy(t => t.Title), - ("title", "desc") => query.OrderByDescending(t => t.Title), + // Sorting + query = (sortBy?.ToLowerInvariant(), sortDir?.ToLowerInvariant()) switch + { + ("title", "asc") => query.OrderBy(t => t.Title), + ("title", "desc") => query.OrderByDescending(t => t.Title), - ("dueatutc", "asc") => query.OrderBy(t => t.DueAtUtc), - ("dueatutc", "desc") => query.OrderByDescending(t => t.DueAtUtc), + ("dueatutc", "asc") => query.OrderBy(t => t.DueAtUtc), + ("dueatutc", "desc") => query.OrderByDescending(t => t.DueAtUtc), - ("priority", "asc") => query.OrderBy(t => t.Priority), - ("priority", "desc") => query.OrderByDescending(t => t.Priority), + ("priority", "asc") => query.OrderBy(t => t.Priority), + ("priority", "desc") => query.OrderByDescending(t => t.Priority), - ("createdatutc", "asc") => query.OrderBy(t => t.CreatedAtUtc), - ("createdatutc", "desc") => query.OrderByDescending(t => t.CreatedAtUtc), + ("createdatutc", "asc") => query.OrderBy(t => t.CreatedAtUtc), + ("createdatutc", "desc") => query.OrderByDescending(t => t.CreatedAtUtc), - ("updatedatutc", "asc") => query.OrderBy(t => t.UpdatedAtUtc), - ("updatedatutc", "desc") => query.OrderByDescending(t => t.UpdatedAtUtc), + ("updatedatutc", "asc") => query.OrderBy(t => t.UpdatedAtUtc), + ("updatedatutc", "desc") => query.OrderByDescending(t => t.UpdatedAtUtc), - _ => query.OrderByDescending(t => t.CreatedAtUtc) - }; + _ => query.OrderByDescending(t => t.CreatedAtUtc) + }; - var total = await query.CountAsync(ct); - var items = await query - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(t => new TodoDto(t.Id, t.Title, t.Description, t.IsCompleted, t.DueAtUtc, t.CreatedAtUtc, t.UpdatedAtUtc, ParseLabels(t.LabelsCsv), t.Priority)) - .ToListAsync(ct); + var total = await query.CountAsync(ct); + var items = await query + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(t => new TodoDto(t.Id, t.Title, t.Description, t.IsCompleted, t.DueAtUtc, t.CreatedAtUtc, t.UpdatedAtUtc, ParseLabels(t.LabelsCsv), t.Priority)) + .ToListAsync(ct); - var fullUrl = http.Request.GetDisplayUrl(); - var uri = new Uri(fullUrl); - string BuildPageLink(int p) + var fullUrl = http.Request.GetDisplayUrl(); + var uri = new Uri(fullUrl); + string BuildPageLink(int p) + { + var qb = QueryString.Create(new Dictionary { - var qb = QueryString.Create(new Dictionary - { - ["page"] = p.ToString(), - ["pageSize"] = pageSize.ToString(), - ["search"] = search, - ["sortBy"] = sortBy, - ["sortDir"] = sortDir, - ["isCompleted"] = isCompleted?.ToString()?.ToLowerInvariant() - }); - var builder = new UriBuilder(uri) { Query = qb.Value }; - return builder.Uri.ToString(); - } + ["page"] = p.ToString(), + ["pageSize"] = pageSize.ToString(), + ["search"] = search, + ["sortBy"] = sortBy, + ["sortDir"] = sortDir, + ["isCompleted"] = isCompleted?.ToString()?.ToLowerInvariant() + }); + var builder = new UriBuilder(uri) { Query = qb.Value }; + return builder.Uri.ToString(); + } - string? next = (page * pageSize < total) ? BuildPageLink(page + 1) : null; - string? prev = (page > 1) ? BuildPageLink(page - 1) : null; + string? next = (page * pageSize < total) ? BuildPageLink(page + 1) : null; + string? prev = (page > 1) ? BuildPageLink(page - 1) : null; - return Results.Ok(new PagedResult(items, total, page, pageSize, next, prev)); - }) + return Results.Ok(new PagedResult(items, total, page, pageSize, next, prev)); + }) .WithName("ListTodos") .Produces>(StatusCodes.Status200OK) .WithSummary("List todos with paging, filtering, sorting") diff --git a/ConsoleApp1/TodoServiceImplementation.cs b/ConsoleApp1/TodoServiceImplementation.cs index 0011a70..8f6d393 100644 --- a/ConsoleApp1/TodoServiceImplementation.cs +++ b/ConsoleApp1/TodoServiceImplementation.cs @@ -12,8 +12,8 @@ public interface ITodoService public class TodoService(AppDbContext db) : ITodoService { public Task GetOverdueCountAsync(CancellationToken ct = default) - { - var now = DateTimeOffset.UtcNow; - return db.Todos.CountAsync(t => !t.IsCompleted && t.DueAtUtc != null && t.DueAtUtc < now, ct); - } +{ + var now = DateTimeOffset.UtcNow; + return db.Todos.CountAsync(t => !t.IsCompleted && t.DueAtUtc != null && t.DueAtUtc < now, ct); +} }