Skip to content

Commit b8bc750

Browse files
feat: Add database connection and entity models
- Add IDbConnectionFactory interface for database abstraction - Implement SqlConnectionFactory for SQL Server connections - Implement OrderRepository with Dapper for stuck order queries - Add OrderItem entity - Configure appsettings.json with connection strings and thresholds - Add DependencyInjection extension for service registration - Update Program.cs with Serilog and infrastructure services - Add unit tests for OrderRepository and SqlConnectionFactory BD-692 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 837335e commit b8bc750

File tree

10 files changed

+473
-37
lines changed

10 files changed

+473
-37
lines changed

src/OrderMonitor.Api/Program.cs

Lines changed: 51 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,59 @@
1-
var builder = WebApplication.CreateBuilder(args);
1+
using OrderMonitor.Infrastructure;
2+
using Serilog;
23

3-
// Add services to the container.
4-
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
5-
builder.Services.AddEndpointsApiExplorer();
6-
builder.Services.AddSwaggerGen();
4+
// Configure Serilog early for startup logging
5+
Log.Logger = new LoggerConfiguration()
6+
.WriteTo.Console()
7+
.CreateBootstrapLogger();
78

8-
var app = builder.Build();
9-
10-
// Configure the HTTP request pipeline.
11-
if (app.Environment.IsDevelopment())
9+
try
1210
{
13-
app.UseSwagger();
14-
app.UseSwaggerUI();
15-
}
11+
Log.Information("Starting Order Monitor API");
1612

17-
var summaries = new[]
18-
{
19-
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
20-
};
13+
var builder = WebApplication.CreateBuilder(args);
14+
15+
// Configure Serilog from appsettings
16+
builder.Host.UseSerilog((context, services, configuration) => configuration
17+
.ReadFrom.Configuration(context.Configuration)
18+
.ReadFrom.Services(services)
19+
.Enrich.FromLogContext()
20+
.WriteTo.Console());
21+
22+
// Add services to the container
23+
builder.Services.AddControllers();
24+
builder.Services.AddEndpointsApiExplorer();
25+
builder.Services.AddSwaggerGen();
26+
27+
// Add infrastructure services (database, repositories)
28+
builder.Services.AddInfrastructure(builder.Configuration);
29+
30+
// Add health checks
31+
builder.Services.AddHealthChecks();
2132

22-
app.MapGet("/weatherforecast", () =>
33+
var app = builder.Build();
34+
35+
// Configure the HTTP request pipeline
36+
if (app.Environment.IsDevelopment())
37+
{
38+
app.UseSwagger();
39+
app.UseSwaggerUI();
40+
}
41+
42+
app.UseSerilogRequestLogging();
43+
app.UseAuthorization();
44+
app.MapControllers();
45+
app.MapHealthChecks("/health");
46+
47+
app.Run();
48+
}
49+
catch (Exception ex)
2350
{
24-
var forecast = Enumerable.Range(1, 5).Select(index =>
25-
new WeatherForecast
26-
(
27-
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
28-
Random.Shared.Next(-20, 55),
29-
summaries[Random.Shared.Next(summaries.Length)]
30-
))
31-
.ToArray();
32-
return forecast;
33-
})
34-
.WithName("GetWeatherForecast")
35-
.WithOpenApi();
36-
37-
app.Run();
38-
39-
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
51+
Log.Fatal(ex, "Application terminated unexpectedly");
52+
}
53+
finally
4054
{
41-
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
55+
Log.CloseAndFlush();
4256
}
57+
58+
// Make the implicit Program class public for testing
59+
public partial class Program { }
Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
11
{
2+
"ConnectionStrings": {
3+
"BackofficeDb": "Server=localhost;Database=Backoffice;Trusted_Connection=True;TrustServerCertificate=True;ApplicationIntent=ReadOnly;"
4+
},
5+
6+
"Scanner": {
7+
"Enabled": false,
8+
"IntervalMinutes": 15,
9+
"BatchSize": 100
10+
},
11+
12+
"Alerts": {
13+
"Enabled": false
14+
},
15+
216
"Logging": {
317
"LogLevel": {
4-
"Default": "Information",
5-
"Microsoft.AspNetCore": "Warning"
18+
"Default": "Debug",
19+
"Microsoft.AspNetCore": "Information",
20+
"OrderMonitor": "Debug"
621
}
722
}
823
}
Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,50 @@
11
{
2+
"ConnectionStrings": {
3+
"BackofficeDb": "Server=localhost;Database=Backoffice;Trusted_Connection=True;TrustServerCertificate=True;ApplicationIntent=ReadOnly;"
4+
},
5+
6+
"StatusThresholds": {
7+
"PrepStatuses": {
8+
"MinStatusId": 3001,
9+
"MaxStatusId": 3910,
10+
"ThresholdHours": 6
11+
},
12+
"FacilityStatuses": {
13+
"MinStatusId": 4001,
14+
"MaxStatusId": 5830,
15+
"ThresholdHours": 48
16+
}
17+
},
18+
19+
"Scanner": {
20+
"Enabled": true,
21+
"IntervalMinutes": 15,
22+
"BatchSize": 1000
23+
},
24+
25+
"Alerts": {
26+
"Enabled": true,
27+
"Recipients": [
28+
"ranganathan.e@syncoms.com"
29+
],
30+
"SubjectPrefix": "[Order Monitor]"
31+
},
32+
33+
"SmtpSettings": {
34+
"Host": "pod51017.outlook.com",
35+
"Port": 587,
36+
"Username": "backoffice@printerpix.com",
37+
"FromEmail": "backoffice@printerpix.com",
38+
"UseSsl": true
39+
},
40+
241
"Logging": {
342
"LogLevel": {
443
"Default": "Information",
5-
"Microsoft.AspNetCore": "Warning"
44+
"Microsoft.AspNetCore": "Warning",
45+
"OrderMonitor": "Debug"
646
}
747
},
48+
849
"AllowedHosts": "*"
950
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
namespace OrderMonitor.Core.Entities;
2+
3+
/// <summary>
4+
/// Represents an item within an order.
5+
/// </summary>
6+
public class OrderItem
7+
{
8+
public int OrderItemId { get; set; }
9+
public string CONumber { get; set; } = string.Empty;
10+
public string ProductType { get; set; } = string.Empty;
11+
public int Quantity { get; set; }
12+
public string? ProductCode { get; set; }
13+
public decimal? UnitPrice { get; set; }
14+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using System.Data;
2+
3+
namespace OrderMonitor.Core.Interfaces;
4+
5+
/// <summary>
6+
/// Factory interface for creating database connections.
7+
/// </summary>
8+
public interface IDbConnectionFactory
9+
{
10+
/// <summary>
11+
/// Creates a new database connection.
12+
/// </summary>
13+
IDbConnection CreateConnection();
14+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
using System.Text;
2+
using Dapper;
3+
using OrderMonitor.Core.Interfaces;
4+
using OrderMonitor.Core.Models;
5+
6+
namespace OrderMonitor.Infrastructure.Data;
7+
8+
/// <summary>
9+
/// Repository implementation for order data access using Dapper.
10+
/// </summary>
11+
public class OrderRepository : IOrderRepository
12+
{
13+
private readonly IDbConnectionFactory _connectionFactory;
14+
15+
public OrderRepository(IDbConnectionFactory connectionFactory)
16+
{
17+
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
18+
}
19+
20+
/// <inheritdoc />
21+
public async Task<IEnumerable<StuckOrderDto>> GetStuckOrdersAsync(
22+
StuckOrderQueryParams queryParams,
23+
CancellationToken cancellationToken = default)
24+
{
25+
const string baseSql = @"
26+
SELECT
27+
o.CONumber AS OrderId,
28+
o.OrderNumber,
29+
o.StatusId,
30+
s.StatusName AS Status,
31+
o.ProductType,
32+
o.StatusUpdatedAt AS StuckSince,
33+
DATEDIFF(HOUR, o.StatusUpdatedAt, GETUTCDATE()) AS HoursStuck,
34+
CASE
35+
WHEN o.StatusId BETWEEN 3001 AND 3910 THEN 6
36+
WHEN o.StatusId BETWEEN 4001 AND 5830 THEN 48
37+
ELSE 24
38+
END AS ThresholdHours,
39+
o.Region,
40+
o.CustomerEmail
41+
FROM Orders o
42+
INNER JOIN OrderStatuses s ON o.StatusId = s.StatusId
43+
WHERE
44+
(
45+
(o.StatusId BETWEEN 3001 AND 3910
46+
AND DATEDIFF(HOUR, o.StatusUpdatedAt, GETUTCDATE()) > 6)
47+
OR
48+
(o.StatusId BETWEEN 4001 AND 5830
49+
AND DATEDIFF(HOUR, o.StatusUpdatedAt, GETUTCDATE()) > 48)
50+
)";
51+
52+
var sqlBuilder = new StringBuilder(baseSql);
53+
var parameters = new DynamicParameters();
54+
55+
// Apply optional filters
56+
if (queryParams.StatusId.HasValue)
57+
{
58+
sqlBuilder.Append(" AND o.StatusId = @StatusId");
59+
parameters.Add("StatusId", queryParams.StatusId.Value);
60+
}
61+
62+
if (!string.IsNullOrWhiteSpace(queryParams.Status))
63+
{
64+
sqlBuilder.Append(" AND s.StatusName LIKE @Status");
65+
parameters.Add("Status", $"%{queryParams.Status}%");
66+
}
67+
68+
if (queryParams.MinHours.HasValue)
69+
{
70+
sqlBuilder.Append(" AND DATEDIFF(HOUR, o.StatusUpdatedAt, GETUTCDATE()) >= @MinHours");
71+
parameters.Add("MinHours", queryParams.MinHours.Value);
72+
}
73+
74+
if (queryParams.MaxHours.HasValue)
75+
{
76+
sqlBuilder.Append(" AND DATEDIFF(HOUR, o.StatusUpdatedAt, GETUTCDATE()) <= @MaxHours");
77+
parameters.Add("MaxHours", queryParams.MaxHours.Value);
78+
}
79+
80+
// Order by hours stuck descending (oldest first)
81+
sqlBuilder.Append(" ORDER BY DATEDIFF(HOUR, o.StatusUpdatedAt, GETUTCDATE()) DESC");
82+
83+
// Apply pagination
84+
sqlBuilder.Append(" OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY");
85+
parameters.Add("Offset", queryParams.Offset);
86+
parameters.Add("Limit", queryParams.Limit);
87+
88+
using var connection = _connectionFactory.CreateConnection();
89+
return await connection.QueryAsync<StuckOrderDto>(
90+
new CommandDefinition(sqlBuilder.ToString(), parameters, cancellationToken: cancellationToken));
91+
}
92+
93+
/// <inheritdoc />
94+
public async Task<int> GetStuckOrdersCountAsync(CancellationToken cancellationToken = default)
95+
{
96+
const string sql = @"
97+
SELECT COUNT(*)
98+
FROM Orders o
99+
WHERE
100+
(
101+
(o.StatusId BETWEEN 3001 AND 3910
102+
AND DATEDIFF(HOUR, o.StatusUpdatedAt, GETUTCDATE()) > 6)
103+
OR
104+
(o.StatusId BETWEEN 4001 AND 5830
105+
AND DATEDIFF(HOUR, o.StatusUpdatedAt, GETUTCDATE()) > 48)
106+
)";
107+
108+
using var connection = _connectionFactory.CreateConnection();
109+
return await connection.ExecuteScalarAsync<int>(
110+
new CommandDefinition(sql, cancellationToken: cancellationToken));
111+
}
112+
113+
/// <inheritdoc />
114+
public async Task<IEnumerable<OrderStatusHistoryDto>> GetOrderStatusHistoryAsync(
115+
string orderId,
116+
CancellationToken cancellationToken = default)
117+
{
118+
const string sql = @"
119+
WITH StatusDurations AS (
120+
SELECT
121+
sh.StatusId,
122+
s.StatusName AS Status,
123+
sh.Timestamp,
124+
LEAD(sh.Timestamp) OVER (ORDER BY sh.Timestamp) AS NextTimestamp
125+
FROM OrderStatusHistory sh
126+
INNER JOIN OrderStatuses s ON sh.StatusId = s.StatusId
127+
WHERE sh.OrderId = @OrderId
128+
)
129+
SELECT
130+
StatusId,
131+
Status,
132+
Timestamp,
133+
CASE
134+
WHEN NextTimestamp IS NULL THEN
135+
CASE
136+
WHEN (StatusId BETWEEN 3001 AND 3910 AND DATEDIFF(HOUR, Timestamp, GETUTCDATE()) > 6)
137+
OR (StatusId BETWEEN 4001 AND 5830 AND DATEDIFF(HOUR, Timestamp, GETUTCDATE()) > 48)
138+
THEN CONCAT(DATEDIFF(HOUR, Timestamp, GETUTCDATE()), 'h+ (STUCK)')
139+
ELSE CONCAT(DATEDIFF(HOUR, Timestamp, GETUTCDATE()), 'h (Current)')
140+
END
141+
ELSE
142+
CASE
143+
WHEN DATEDIFF(MINUTE, Timestamp, NextTimestamp) < 60
144+
THEN CONCAT(DATEDIFF(MINUTE, Timestamp, NextTimestamp), 'm')
145+
ELSE CONCAT(DATEDIFF(HOUR, Timestamp, NextTimestamp), 'h ',
146+
DATEDIFF(MINUTE, Timestamp, NextTimestamp) % 60, 'm')
147+
END
148+
END AS Duration,
149+
CASE
150+
WHEN NextTimestamp IS NULL AND
151+
((StatusId BETWEEN 3001 AND 3910 AND DATEDIFF(HOUR, Timestamp, GETUTCDATE()) > 6)
152+
OR (StatusId BETWEEN 4001 AND 5830 AND DATEDIFF(HOUR, Timestamp, GETUTCDATE()) > 48))
153+
THEN 1
154+
ELSE 0
155+
END AS IsStuck
156+
FROM StatusDurations
157+
ORDER BY Timestamp ASC";
158+
159+
using var connection = _connectionFactory.CreateConnection();
160+
return await connection.QueryAsync<OrderStatusHistoryDto>(
161+
new CommandDefinition(sql, new { OrderId = orderId }, cancellationToken: cancellationToken));
162+
}
163+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Data;
2+
using Microsoft.Data.SqlClient;
3+
using OrderMonitor.Core.Interfaces;
4+
5+
namespace OrderMonitor.Infrastructure.Data;
6+
7+
/// <summary>
8+
/// SQL Server connection factory implementation.
9+
/// </summary>
10+
public class SqlConnectionFactory : IDbConnectionFactory
11+
{
12+
private readonly string _connectionString;
13+
14+
public SqlConnectionFactory(string connectionString)
15+
{
16+
if (string.IsNullOrWhiteSpace(connectionString))
17+
throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString));
18+
19+
_connectionString = connectionString;
20+
}
21+
22+
/// <inheritdoc />
23+
public IDbConnection CreateConnection()
24+
{
25+
return new SqlConnection(_connectionString);
26+
}
27+
}

0 commit comments

Comments
 (0)