feat: configure startup — CORS, JWT, NSwag, Polly migration retry
This commit is contained in:
parent
6099e99a46
commit
af495cc032
@ -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);
|
||||
|
||||
144
src/AccountTracking.Api/Services/CsvImportService.cs
Normal file
144
src/AccountTracking.Api/Services/CsvImportService.cs
Normal 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 };
|
||||
}
|
||||
91
src/AccountTracking.Api/Services/DashboardService.cs
Normal file
91
src/AccountTracking.Api/Services/DashboardService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user