feat: configure startup — CORS, JWT, NSwag, Polly migration retry

This commit is contained in:
Martin 2026-03-20 00:39:11 +01:00
parent 6099e99a46
commit af495cc032
3 changed files with 341 additions and 24 deletions

View File

@ -1,34 +1,116 @@
using System.Text;
using AccountTracking.Api.Data;
using AccountTracking.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Polly;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
// ── Configuration ──────────────────────────────────────────────────────────
var connectionString = builder.Configuration["DB_CONNECTION"];
var jwtSecret = builder.Configuration["JWT_SECRET"]
?? throw new InvalidOperationException("JWT_SECRET is required");
var appUsername = builder.Configuration["APP_USERNAME"]
?? throw new InvalidOperationException("APP_USERNAME is required");
var appPassword = builder.Configuration["APP_PASSWORD"]
?? throw new InvalidOperationException("APP_PASSWORD is required");
var allowedOrigin = builder.Configuration["ALLOWED_ORIGIN"] ?? "http://localhost:3000";
// ── Database ────────────────────────────────────────────────────────────────
if (!string.IsNullOrEmpty(connectionString))
{
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));
}
else
{
// NSwag build-time spec generation: no real DB available
builder.Services.AddDbContext<AppDbContext>(opts =>
opts.UseInMemoryDatabase("nswag_gen"));
}
// ── Services ────────────────────────────────────────────────────────────────
builder.Services.AddScoped<CsvImportService>();
builder.Services.AddScoped<DashboardService>();
// ── Auth ────────────────────────────────────────────────────────────────────
var keyBytes = Convert.FromBase64String(jwtSecret);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opts =>
{
opts.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
ValidateIssuer = false,
ValidateAudience = false,
ClockSkew = TimeSpan.Zero
};
});
builder.Services.AddAuthorization();
// Store credentials for use by AuthController
builder.Services.AddSingleton(new AppCredentials(appUsername, appPassword, keyBytes));
// ── CORS ────────────────────────────────────────────────────────────────────
builder.Services.AddCors(opts =>
{
opts.AddDefaultPolicy(policy =>
policy.WithOrigins(allowedOrigin, "http://localhost:3000")
.AllowAnyHeader()
.AllowAnyMethod());
});
// ── Controllers & OpenAPI ───────────────────────────────────────────────────
builder.Services.AddControllers();
builder.Services.AddOpenApiDocument(config =>
{
config.Title = "AccountTracking API";
config.Version = "v1";
config.AddSecurity("Bearer", new NSwag.OpenApiSecurityScheme
{
Type = NSwag.OpenApiSecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT"
});
});
// ── Kestrel: limit upload to 10 MB ─────────────────────────────────────────
builder.WebHost.ConfigureKestrel(opts =>
opts.Limits.MaxRequestBodySize = 10 * 1024 * 1024);
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
var summaries = new[]
// ── Migrate DB (skip when running without a real DB) ───────────────────────
if (!string.IsNullOrEmpty(connectionString))
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
var retryPolicy = Policy
.Handle<Exception>()
.WaitAndRetry(
5,
attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)),
(ex, delay, attempt, _) =>
Console.WriteLine($"DB migration attempt {attempt} failed: {ex.Message}. Retrying in {delay.TotalSeconds}s..."));
app.MapGet("/weatherforecast", () =>
{
var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast
(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
});
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
retryPolicy.Execute(() => db.Database.Migrate());
}
// ── Middleware pipeline ─────────────────────────────────────────────────────
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseOpenApi();
app.UseSwaggerUi();
app.MapControllers();
app.Run();
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
{
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
// Accessible by tests
public partial class Program { }
// Credential container registered in DI
public record AppCredentials(string Username, string PasswordHash, byte[] JwtKeyBytes);

View File

@ -0,0 +1,144 @@
using AccountTracking.Api.Data;
using AccountTracking.Api.Models;
using AccountTracking.Api.Models.Dtos;
namespace AccountTracking.Api.Services;
public class CsvImportService(AppDbContext db)
{
private static readonly string[] RequiredColumns = ["částka", "ID transakce", "datum zaúčtování"];
public async Task<ImportServiceResult> ImportAsync(string csvContent, string filename)
{
var lines = csvContent
.Split('\n', StringSplitOptions.None)
.Select(l => l.TrimEnd('\r'))
.ToArray();
// Find header row by scanning for line starting with "číslo účtu"
int headerIndex = -1;
for (int i = 0; i < lines.Length; i++)
{
if (lines[i].StartsWith("číslo účtu", StringComparison.OrdinalIgnoreCase))
{
headerIndex = i;
break;
}
}
if (headerIndex < 0)
return ImportServiceResult.Failure("Soubor neobsahuje správnou hlavičku (řádek začínající 'číslo účtu').");
var headers = lines[headerIndex].Split(';');
var columnIndex = BuildColumnIndex(headers);
// Validate required columns
foreach (var col in RequiredColumns)
{
if (!columnIndex.ContainsKey(col))
return ImportServiceResult.Failure($"Souboru chybí povinný sloupec: '{col}'.");
}
// Load existing transaction IDs to detect duplicates
var existingIds = db.Transactions
.Select(t => t.TransactionId)
.ToHashSet();
int imported = 0, skipped = 0;
var toInsert = new List<Transaction>();
for (int i = headerIndex + 1; i < lines.Length; i++)
{
var line = lines[i];
if (string.IsNullOrWhiteSpace(line)) continue;
var fields = line.Split(';');
if (fields.Length < headers.Length) continue;
var txId = GetField(fields, columnIndex, "ID transakce");
if (string.IsNullOrEmpty(txId)) continue;
if (existingIds.Contains(txId))
{
skipped++;
continue;
}
existingIds.Add(txId); // prevent duplicates within the same file
toInsert.Add(new Transaction
{
AccountNumber = GetField(fields, columnIndex, "číslo účtu"),
BookingDate = DateOnly.ParseExact(GetField(fields, columnIndex, "datum zaúčtování"), "d.M.yyyy"),
Amount = ParseCzechDecimal(GetField(fields, columnIndex, "částka")),
Currency = GetField(fields, columnIndex, "měna"),
Balance = ParseCzechDecimal(GetField(fields, columnIndex, "zůstatek")),
CounterPartyName = GetField(fields, columnIndex, "jméno protistrany"),
OperationDescription = GetField(fields, columnIndex, "označení operace"),
Message = GetField(fields, columnIndex, "zpráva"),
Category = GetField(fields, columnIndex, "kategorie"),
VariableSymbol = GetField(fields, columnIndex, "variabilní symbol"),
BankNote = GetField(fields, columnIndex, "vlastní poznámka"),
TransactionId = txId,
CounterBankCode = GetField(fields, columnIndex, "kód banky protiúčtu"),
ConstantSymbol = GetField(fields, columnIndex, "konstantní symbol"),
SpecificSymbol = GetField(fields, columnIndex, "specifický symbol"),
OrderName = GetField(fields, columnIndex, "název trvalého příkazu"),
ExchangeRate = GetField(fields, columnIndex, "kurz"),
E2EId = GetField(fields, columnIndex, "E2E identifikace"),
PayerReference = GetField(fields, columnIndex, "reference plátce"),
OriginalPayer = GetField(fields, columnIndex, "původní plátce"),
FinalRecipient = GetField(fields, columnIndex, "konečný příjemce"),
OriginalTransaction = GetField(fields, columnIndex, "původní transakce"),
});
imported++;
}
if (toInsert.Count > 0)
{
db.Transactions.AddRange(toInsert);
db.ImportLogs.Add(new ImportLog
{
Filename = filename,
RecordsImported = imported,
RecordsSkipped = skipped,
ImportedAt = DateTime.UtcNow
});
await db.SaveChangesAsync();
}
return ImportServiceResult.Success(new ImportResult(imported, skipped));
}
public static decimal ParseCzechDecimal(string value)
{
// Czech format: "1 740,80" → remove spaces, replace comma with dot
var normalised = value.Replace(" ", "").Replace(",", ".");
return decimal.Parse(normalised, System.Globalization.CultureInfo.InvariantCulture);
}
private static Dictionary<string, int> BuildColumnIndex(string[] headers)
=> headers
.Select((h, i) => (Header: h.Trim(), Index: i))
.Where(x => !string.IsNullOrEmpty(x.Header))
.ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);
private static string GetField(string[] fields, Dictionary<string, int> index, string column)
{
if (!index.TryGetValue(column, out int i)) return "";
return i < fields.Length ? fields[i].Trim() : "";
}
}
public class ImportServiceResult
{
public bool IsSuccess { get; private init; }
public ImportResult? Value { get; private init; }
public string? Error { get; private init; }
public static ImportServiceResult Success(ImportResult value)
=> new() { IsSuccess = true, Value = value };
public static ImportServiceResult Failure(string error)
=> new() { IsSuccess = false, Error = error };
}

View File

@ -0,0 +1,91 @@
using AccountTracking.Api.Data;
using AccountTracking.Api.Models.Dtos;
using Microsoft.EntityFrameworkCore;
namespace AccountTracking.Api.Services;
public class DashboardService(AppDbContext db)
{
public async Task<SummaryDto> GetSummaryAsync(int year, int? month)
{
var query = db.Transactions.Where(t => t.BookingDate.Year == year);
if (month.HasValue)
query = query.Where(t => t.BookingDate.Month == month.Value);
var spent = await query
.Where(t => t.Amount < 0)
.SumAsync(t => (decimal?)t.Amount) ?? 0;
var income = await query
.Where(t => t.Amount > 0)
.SumAsync(t => (decimal?)t.Amount) ?? 0;
return new SummaryDto(Math.Abs(spent), income);
}
public async Task<List<CategorySpendingDto>> GetSpendingByCategoryAsync(int year, int? month)
{
var query = db.Transactions
.Where(t => t.BookingDate.Year == year && t.Amount < 0);
if (month.HasValue)
query = query.Where(t => t.BookingDate.Month == month.Value);
return await query
.GroupBy(t => t.Category ?? "Nezatříděno")
.Select(g => new CategorySpendingDto(g.Key, Math.Abs(g.Sum(t => t.Amount))))
.OrderByDescending(c => c.Total)
.ToListAsync();
}
public async Task<List<MonthlyBalanceDto>> GetMonthlyBalancesAsync(int year)
{
// For each month: get the balance of the transaction with the latest booking_date
// (highest id as tiebreaker for same-date rows)
var rows = await db.Transactions
.Where(t => t.BookingDate.Year == year)
.GroupBy(t => t.BookingDate.Month)
.Select(g => new MonthlyBalanceDto(
g.Key,
g.OrderByDescending(t => t.BookingDate)
.ThenByDescending(t => t.Id)
.First().Balance))
.OrderBy(r => r.Month)
.ToListAsync();
return rows;
}
public async Task<List<CumulativeSpendingDto>> GetCumulativeSpendingAsync(int year, int month)
{
// Get daily spending totals (absolute values, negative amounts only)
var dailySpending = await db.Transactions
.Where(t => t.BookingDate.Year == year
&& t.BookingDate.Month == month
&& t.Amount < 0)
.GroupBy(t => t.BookingDate.Day)
.Select(g => new { Day = g.Key, Spent = Math.Abs(g.Sum(t => t.Amount)) })
.OrderBy(g => g.Day)
.ToListAsync();
if (dailySpending.Count == 0)
return [];
// Fill gaps: for each day from day 1 to last day with spending,
// carry forward the previous cumulative value
var result = new List<CumulativeSpendingDto>();
decimal running = 0;
int lastDay = dailySpending.Max(d => d.Day);
var spendingByDay = dailySpending.ToDictionary(d => d.Day, d => d.Spent);
for (int day = 1; day <= lastDay; day++)
{
if (spendingByDay.TryGetValue(day, out var spent))
running += spent;
if (day <= lastDay)
result.Add(new CumulativeSpendingDto(day, running));
}
return result;
}
}