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);
|
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();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// ── Migrate DB (skip when running without a real DB) ───────────────────────
|
||||||
|
if (!string.IsNullOrEmpty(connectionString))
|
||||||
app.UseHttpsRedirection();
|
|
||||||
|
|
||||||
var summaries = new[]
|
|
||||||
{
|
{
|
||||||
"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", () =>
|
using var scope = app.Services.CreateScope();
|
||||||
{
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
var forecast = Enumerable.Range(1, 5).Select(index =>
|
retryPolicy.Execute(() => db.Database.Migrate());
|
||||||
new WeatherForecast
|
}
|
||||||
(
|
|
||||||
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
|
// ── Middleware pipeline ─────────────────────────────────────────────────────
|
||||||
Random.Shared.Next(-20, 55),
|
app.UseCors();
|
||||||
summaries[Random.Shared.Next(summaries.Length)]
|
app.UseAuthentication();
|
||||||
))
|
app.UseAuthorization();
|
||||||
.ToArray();
|
app.UseOpenApi();
|
||||||
return forecast;
|
app.UseSwaggerUi();
|
||||||
});
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary)
|
// Accessible by tests
|
||||||
{
|
public partial class Program { }
|
||||||
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
|
|
||||||
}
|
// 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