Private
Public Access
1
0
Files
Purrse/src/Purrse.Api/Program.cs
T
Catherine Renelle b48cb1c48c Mark existing bank-synced transactions as Cleared on startup
One-time migration: UPDATE all transactions that have a FitId
(bank-synced) and Status=Uncleared to Status=Cleared. Tracked
in data_migrations table so it only runs once.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 20:56:26 -05:00

158 lines
6.0 KiB
C#

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Purrse.Api.Hubs;
using Purrse.Api.Middleware;
using Purrse.Api.Services;
using Purrse.Core.Interfaces.Services;
using Purrse.Data;
using Purrse.Plugins.Abstractions;
using Purrse.Plugins.BankSync;
using Purrse.Plugins.CSV;
using Purrse.Plugins.OFX;
using Purrse.Plugins.QIF;
var builder = WebApplication.CreateBuilder(args);
// Database
builder.Services.AddDbContext<PurrseDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
// JWT Authentication
var jwtKey = builder.Configuration["Jwt:Key"] ?? "PurrseDefaultSecretKey_ChangeInProduction_AtLeast32Chars!";
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "Purrse",
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "Purrse",
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
// SignalR JWT support
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
// CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.WithOrigins(
builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? new[] { "http://localhost:5173", "http://localhost:8080" })
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
// Services
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAccountService, AccountService>();
builder.Services.AddScoped<ITransactionService, TransactionService>();
builder.Services.AddScoped<ICategoryService, CategoryService>();
builder.Services.AddScoped<IPayeeService, PayeeService>();
builder.Services.AddScoped<IBudgetService, BudgetService>();
builder.Services.AddScoped<IScheduledTransactionService, ScheduledTransactionService>();
builder.Services.AddScoped<IImportService, ImportService>();
builder.Services.AddScoped<ILoanService, LoanService>();
builder.Services.AddScoped<IDashboardService, DashboardService>();
builder.Services.AddScoped<IReportService, ReportService>();
builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService>();
builder.Services.AddScoped<IEncryptionService, EncryptionService>();
builder.Services.AddScoped<IAiCategorizationService, AiCategorizationService>();
builder.Services.AddScoped<IBankSyncService, BankSyncService>();
builder.Services.AddHttpClient("Ollama");
// Built-in file parsers
builder.Services.AddSingleton<IFileParser, OFXParserPlugin>();
builder.Services.AddSingleton<IFileParser, CSVParserPlugin>();
builder.Services.AddSingleton<IFileParser, QIFParserPlugin>();
// Bank sync providers
builder.Services.AddSingleton<PlaidSyncProvider>(); // Stateless — credentials passed per-call
builder.Services.AddHttpClient<SimpleFinSyncProvider>();
// Plugin system
builder.Services.AddSingleton<PluginService>();
// Background services
builder.Services.AddHostedService<ScheduledTransactionProcessor>();
builder.Services.AddHostedService<FileWatcherService>();
builder.Services.AddHostedService<BankSyncBackgroundService>();
// SignalR
builder.Services.AddSignalR();
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
});
builder.Services.AddEndpointsApiExplorer();
var app = builder.Build();
// Auto-migrate database
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<PurrseDbContext>();
await db.Database.MigrateAsync();
}
// One-time data migrations
await SimpleFinConnectionMigration.RunAsync(app.Services, app.Services.GetRequiredService<ILogger<Program>>());
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<PurrseDbContext>();
await db.Database.ExecuteSqlRawAsync(
"CREATE TABLE IF NOT EXISTS data_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMP NOT NULL)");
var applied = await db.Database.SqlQueryRaw<int>(
"SELECT 1 AS \"Value\" FROM data_migrations WHERE name = 'ClearSyncedTransactions'").AnyAsync();
if (!applied)
{
var count = await db.Database.ExecuteSqlRawAsync(
"UPDATE \"Transactions\" SET \"Status\" = 1 WHERE \"FitId\" IS NOT NULL AND \"Status\" = 0");
if (count > 0)
app.Services.GetRequiredService<ILogger<Program>>().LogInformation("Marked {Count} bank-synced transactions as Cleared", count);
await db.Database.ExecuteSqlRawAsync(
"INSERT INTO data_migrations (name, applied_at) VALUES ('ClearSyncedTransactions', NOW()) ON CONFLICT DO NOTHING");
}
}
// Initialize plugin system
var pluginService = app.Services.GetRequiredService<PluginService>();
await pluginService.DiscoverAndLoadPluginsAsync();
// Middleware
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<NotificationHub>("/hubs/notifications");
app.Run();