diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b424591 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.cs] +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion + +[*.{json,yml,yaml}] +indent_size = 2 diff --git a/Background/VehicleRetentionCleanupService.cs b/Background/VehicleRetentionCleanupService.cs index 6bf35ca..9381a9c 100644 --- a/Background/VehicleRetentionCleanupService.cs +++ b/Background/VehicleRetentionCleanupService.cs @@ -1,3 +1,4 @@ +using TransitAnalyticsAPI.Models.Entities; using TransitAnalyticsAPI.Services; namespace TransitAnalyticsAPI.Background; @@ -9,13 +10,16 @@ public class VehicleRetentionCleanupService : BackgroundService private readonly IServiceScopeFactory _serviceScopeFactory; private readonly ILogger _logger; + private readonly ISystemLogService _systemLog; public VehicleRetentionCleanupService( IServiceScopeFactory serviceScopeFactory, - ILogger logger) + ILogger logger, + ISystemLogService systemLogService) { _serviceScopeFactory = serviceScopeFactory; _logger = logger; + _systemLog = systemLogService; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -52,9 +56,8 @@ private async Task RunCleanupAsync(CancellationToken cancellationToken) var deletedCount = await retentionService.DeleteExpiredAsync(cancellationToken); - _logger.LogInformation( - "Vehicle retention cleanup completed. Deleted {DeletedCount} expired vehicle positions.", - deletedCount); + await _systemLog.LogAsync(SystemLogType.Info, "Vehicle retention cleanup completed", + $"Deleted {deletedCount} expired vehicle positions.", cancellationToken); } catch (OperationCanceledException) { @@ -62,7 +65,8 @@ private async Task RunCleanupAsync(CancellationToken cancellationToken) } catch (Exception exception) { - _logger.LogError(exception, "Vehicle retention cleanup failed."); + await _systemLog.LogAsync(SystemLogType.Error, "Vehicle retention cleanup failed", exception.ToString(), + cancellationToken); } } diff --git a/Configuration/VehicleOptions.cs b/Configuration/VehicleOptions.cs index ff95469..5b38859 100644 --- a/Configuration/VehicleOptions.cs +++ b/Configuration/VehicleOptions.cs @@ -6,5 +6,5 @@ public class VehicleOptions public int LatestPositionMaxAgeMinutes { get; set; } = 5; - public int HistoryRetentionDays { get; set; } = 7; + public int HistoryRetentionDays { get; set; } = 3; } diff --git a/Migrations/20260315044948_InitialCreate.cs b/Migrations/20260315044948_InitialCreate.cs index c5cebbf..52734d5 100644 --- a/Migrations/20260315044948_InitialCreate.cs +++ b/Migrations/20260315044948_InitialCreate.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; diff --git a/Migrations/20260316031315_AddGtfsRoutesAndTrips.cs b/Migrations/20260316031315_AddGtfsRoutesAndTrips.cs index 0d401a6..b2836fa 100644 --- a/Migrations/20260316031315_AddGtfsRoutesAndTrips.cs +++ b/Migrations/20260316031315_AddGtfsRoutesAndTrips.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; diff --git a/Migrations/20260317103126_AddGtfsRouteShapesAndStops.cs b/Migrations/20260317103126_AddGtfsRouteShapesAndStops.cs index 7a2a80b..2851e71 100644 --- a/Migrations/20260317103126_AddGtfsRouteShapesAndStops.cs +++ b/Migrations/20260317103126_AddGtfsRouteShapesAndStops.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/Migrations/20260321052221_AddAdminSettings.cs b/Migrations/20260321052221_AddAdminSettings.cs index 481ab8f..a576249 100644 --- a/Migrations/20260321052221_AddAdminSettings.cs +++ b/Migrations/20260321052221_AddAdminSettings.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/Migrations/20260321063732_AddAdminGtfsUploadStatus.cs b/Migrations/20260321063732_AddAdminGtfsUploadStatus.cs index 9a60c9c..0b60c0f 100644 --- a/Migrations/20260321063732_AddAdminGtfsUploadStatus.cs +++ b/Migrations/20260321063732_AddAdminGtfsUploadStatus.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/Migrations/20260321064017_AddAdminPollingToggle.cs b/Migrations/20260321064017_AddAdminPollingToggle.cs index 90d1eba..8a16b91 100644 --- a/Migrations/20260321064017_AddAdminPollingToggle.cs +++ b/Migrations/20260321064017_AddAdminPollingToggle.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/Migrations/20260321101936_AddVehiclePositionQueryIndexes.cs b/Migrations/20260321101936_AddVehiclePositionQueryIndexes.cs index 43d7334..49a74fa 100644 --- a/Migrations/20260321101936_AddVehiclePositionQueryIndexes.cs +++ b/Migrations/20260321101936_AddVehiclePositionQueryIndexes.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/Migrations/20260331014039_AddSystemLog.Designer.cs b/Migrations/20260331014039_AddSystemLog.Designer.cs new file mode 100644 index 0000000..b7ddb9b --- /dev/null +++ b/Migrations/20260331014039_AddSystemLog.Designer.cs @@ -0,0 +1,530 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TransitAnalyticsAPI.Persistence; + +#nullable disable + +namespace TransitAnalyticsAPI.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260331014039_AddSystemLog")] + partial class AddSystemLog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.AdminSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsMaintenanceMode") + .HasColumnType("boolean") + .HasColumnName("is_maintenance_mode"); + + b.Property("IsPollingEnabled") + .HasColumnType("boolean") + .HasColumnName("is_polling_enabled"); + + b.Property("LastGtfsImportError") + .HasColumnType("text") + .HasColumnName("last_gtfs_import_error"); + + b.Property("LastGtfsImportStatus") + .HasColumnType("text") + .HasColumnName("last_gtfs_import_status"); + + b.Property("LastGtfsSourceVersion") + .HasColumnType("text") + .HasColumnName("last_gtfs_source_version"); + + b.Property("LastGtfsUploadAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_gtfs_upload_at_utc"); + + b.Property("LastGtfsUploadFileName") + .HasColumnType("text") + .HasColumnName("last_gtfs_upload_file_name"); + + b.HasKey("Id") + .HasName("pk_admin_settings"); + + b.ToTable("admin_settings", (string)null); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsImportRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed_at_utc"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("Notes") + .HasColumnType("text") + .HasColumnName("notes"); + + b.Property("SourceVersion") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source_version"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at_utc"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text") + .HasColumnName("status"); + + b.HasKey("Id") + .HasName("pk_gtfs_import_runs"); + + b.ToTable("gtfs_import_runs", (string)null); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsRoute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgencyId") + .HasColumnType("text") + .HasColumnName("agency_id"); + + b.Property("ImportRunId") + .HasColumnType("bigint") + .HasColumnName("import_run_id"); + + b.Property("RouteColor") + .HasColumnType("text") + .HasColumnName("route_color"); + + b.Property("RouteId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("route_id"); + + b.Property("RouteLongName") + .HasColumnType("text") + .HasColumnName("route_long_name"); + + b.Property("RouteShortName") + .HasColumnType("text") + .HasColumnName("route_short_name"); + + b.Property("RouteTextColor") + .HasColumnType("text") + .HasColumnName("route_text_color"); + + b.Property("RouteType") + .HasColumnType("integer") + .HasColumnName("route_type"); + + b.HasKey("Id") + .HasName("pk_gtfs_routes"); + + b.HasIndex("ImportRunId") + .HasDatabaseName("ix_gtfs_routes_import_run_id"); + + b.ToTable("gtfs_routes", (string)null); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsShapePoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DistanceTraveled") + .HasColumnType("double precision") + .HasColumnName("distance_traveled"); + + b.Property("ImportRunId") + .HasColumnType("bigint") + .HasColumnName("import_run_id"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasColumnName("latitude"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasColumnName("longitude"); + + b.Property("Sequence") + .HasColumnType("integer") + .HasColumnName("sequence"); + + b.Property("ShapeId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("shape_id"); + + b.HasKey("Id") + .HasName("pk_gtfs_shape_points"); + + b.HasIndex("ImportRunId") + .HasDatabaseName("ix_gtfs_shape_points_import_run_id"); + + b.ToTable("gtfs_shape_points", (string)null); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsStop", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("bigint") + .HasColumnName("import_run_id"); + + b.Property("LocationType") + .HasColumnType("integer") + .HasColumnName("location_type"); + + b.Property("ParentStation") + .HasColumnType("text") + .HasColumnName("parent_station"); + + b.Property("PlatformCode") + .HasColumnType("text") + .HasColumnName("platform_code"); + + b.Property("StopCode") + .HasColumnType("text") + .HasColumnName("stop_code"); + + b.Property("StopId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("stop_id"); + + b.Property("StopLat") + .HasColumnType("double precision") + .HasColumnName("stop_lat"); + + b.Property("StopLon") + .HasColumnType("double precision") + .HasColumnName("stop_lon"); + + b.Property("StopName") + .HasColumnType("text") + .HasColumnName("stop_name"); + + b.HasKey("Id") + .HasName("pk_gtfs_stops"); + + b.HasIndex("ImportRunId") + .HasDatabaseName("ix_gtfs_stops_import_run_id"); + + b.ToTable("gtfs_stops", (string)null); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsStopTime", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("bigint") + .HasColumnName("import_run_id"); + + b.Property("ShapeDistTraveled") + .HasColumnType("double precision") + .HasColumnName("shape_dist_traveled"); + + b.Property("StopHeadsign") + .HasColumnType("text") + .HasColumnName("stop_headsign"); + + b.Property("StopId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("stop_id"); + + b.Property("StopSequence") + .HasColumnType("integer") + .HasColumnName("stop_sequence"); + + b.Property("TripId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trip_id"); + + b.HasKey("Id") + .HasName("pk_gtfs_stop_times"); + + b.HasIndex("ImportRunId") + .HasDatabaseName("ix_gtfs_stop_times_import_run_id"); + + b.ToTable("gtfs_stop_times", (string)null); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsTrip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DirectionId") + .HasColumnType("integer") + .HasColumnName("direction_id"); + + b.Property("ImportRunId") + .HasColumnType("bigint") + .HasColumnName("import_run_id"); + + b.Property("RouteId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("route_id"); + + b.Property("ServiceId") + .HasColumnType("text") + .HasColumnName("service_id"); + + b.Property("ShapeId") + .HasColumnType("text") + .HasColumnName("shape_id"); + + b.Property("TripHeadsign") + .HasColumnType("text") + .HasColumnName("trip_headsign"); + + b.Property("TripId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trip_id"); + + b.HasKey("Id") + .HasName("pk_gtfs_trips"); + + b.HasIndex("ImportRunId") + .HasDatabaseName("ix_gtfs_trips_import_run_id"); + + b.ToTable("gtfs_trips", (string)null); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.SystemLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Details") + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_system_logs"); + + b.ToTable("system_logs", (string)null); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.VehiclePosition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bearing") + .HasColumnType("text") + .HasColumnName("bearing"); + + b.Property("IngestedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("ingested_at_utc"); + + b.Property("Latitude") + .HasColumnType("double precision") + .HasColumnName("latitude"); + + b.Property("Longitude") + .HasColumnType("double precision") + .HasColumnName("longitude"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at_utc"); + + b.Property("RouteId") + .HasColumnType("text") + .HasColumnName("route_id"); + + b.Property("SourceEntityId") + .HasColumnType("text") + .HasColumnName("source_entity_id"); + + b.Property("Speed") + .HasColumnType("double precision") + .HasColumnName("speed"); + + b.Property("TripId") + .HasColumnType("text") + .HasColumnName("trip_id"); + + b.Property("VehicleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("vehicle_id"); + + b.HasKey("Id") + .HasName("pk_vehicle_positions"); + + b.HasIndex("RecordedAtUtc") + .HasDatabaseName("ix_vehicle_positions_recorded_at_utc"); + + b.HasIndex("RouteId", "RecordedAtUtc") + .HasDatabaseName("ix_vehicle_positions_route_id_recorded_at_utc"); + + b.HasIndex("VehicleId", "RecordedAtUtc") + .HasDatabaseName("ix_vehicle_positions_vehicle_id_recorded_at_utc"); + + b.ToTable("vehicle_positions", (string)null); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsRoute", b => + { + b.HasOne("TransitAnalyticsAPI.Models.Entities.GtfsImportRun", "ImportRun") + .WithMany("Routes") + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gtfs_routes_gtfs_import_runs_import_run_id"); + + b.Navigation("ImportRun"); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsShapePoint", b => + { + b.HasOne("TransitAnalyticsAPI.Models.Entities.GtfsImportRun", "ImportRun") + .WithMany("ShapePoints") + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gtfs_shape_points_gtfs_import_runs_import_run_id"); + + b.Navigation("ImportRun"); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsStop", b => + { + b.HasOne("TransitAnalyticsAPI.Models.Entities.GtfsImportRun", "ImportRun") + .WithMany("Stops") + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gtfs_stops_gtfs_import_runs_import_run_id"); + + b.Navigation("ImportRun"); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsStopTime", b => + { + b.HasOne("TransitAnalyticsAPI.Models.Entities.GtfsImportRun", "ImportRun") + .WithMany("StopTimes") + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gtfs_stop_times_gtfs_import_runs_import_run_id"); + + b.Navigation("ImportRun"); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsTrip", b => + { + b.HasOne("TransitAnalyticsAPI.Models.Entities.GtfsImportRun", "ImportRun") + .WithMany("Trips") + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_gtfs_trips_gtfs_import_runs_import_run_id"); + + b.Navigation("ImportRun"); + }); + + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.GtfsImportRun", b => + { + b.Navigation("Routes"); + + b.Navigation("ShapePoints"); + + b.Navigation("StopTimes"); + + b.Navigation("Stops"); + + b.Navigation("Trips"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260331014039_AddSystemLog.cs b/Migrations/20260331014039_AddSystemLog.cs new file mode 100644 index 0000000..5c86ccc --- /dev/null +++ b/Migrations/20260331014039_AddSystemLog.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace TransitAnalyticsAPI.Migrations +{ + /// + public partial class AddSystemLog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "system_logs", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + created_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + type = table.Column(type: "integer", nullable: false), + source = table.Column(type: "text", nullable: false), + description = table.Column(type: "text", nullable: false), + details = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_system_logs", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "system_logs"); + } + } +} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs index 1746ec2..5848d96 100644 --- a/Migrations/AppDbContextModelSnapshot.cs +++ b/Migrations/AppDbContextModelSnapshot.cs @@ -347,6 +347,43 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("gtfs_trips", (string)null); }); + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.SystemLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Details") + .HasColumnType("text") + .HasColumnName("details"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_system_logs"); + + b.ToTable("system_logs", (string)null); + }); + modelBuilder.Entity("TransitAnalyticsAPI.Models.Entities.VehiclePosition", b => { b.Property("Id") diff --git a/Models/Entities/SystemLog.cs b/Models/Entities/SystemLog.cs new file mode 100644 index 0000000..65da9c8 --- /dev/null +++ b/Models/Entities/SystemLog.cs @@ -0,0 +1,16 @@ +namespace TransitAnalyticsAPI.Models.Entities; + +public class SystemLog +{ + public long Id { get; set; } + + public DateTime CreatedAtUtc { get; set; } + + public SystemLogType Type { get; set; } + + public string Source { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + public string? Details { get; set; } +} diff --git a/Models/Entities/SystemLogType.cs b/Models/Entities/SystemLogType.cs new file mode 100644 index 0000000..20f6a70 --- /dev/null +++ b/Models/Entities/SystemLogType.cs @@ -0,0 +1,8 @@ +namespace TransitAnalyticsAPI.Models.Entities; + +public enum SystemLogType +{ + Info, + Warning, + Error +} diff --git a/Persistence/AppDbContext.cs b/Persistence/AppDbContext.cs index 20f2d87..4f8e921 100644 --- a/Persistence/AppDbContext.cs +++ b/Persistence/AppDbContext.cs @@ -23,6 +23,8 @@ public AppDbContext(DbContextOptions options) : base(options) public DbSet GtfsTrips => Set(); + public DbSet SystemLogs => Set(); + public DbSet VehiclePositions => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) diff --git a/Program.cs b/Program.cs index 7f3caeb..551dbb6 100644 --- a/Program.cs +++ b/Program.cs @@ -44,6 +44,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(typeof(ISystemLogService<>), typeof(SystemLogService<>)); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Services/ISystemLogService.cs b/Services/ISystemLogService.cs new file mode 100644 index 0000000..0a44cad --- /dev/null +++ b/Services/ISystemLogService.cs @@ -0,0 +1,36 @@ +using TransitAnalyticsAPI.Models.Entities; + +namespace TransitAnalyticsAPI.Services; + +/// +/// Persists application-level log entries to the database. +/// The source component name is derived automatically from . +/// +/// The component class that is producing the log entries. +/// +/// +/// // Inject into your class: +/// private readonly ISystemLogService<MyService> _systemLog; +/// +/// // Log an informational message: +/// await _systemLog.LogAsync(SystemLogType.Info, "Deleted 42 expired positions"); +/// +/// // Log an error with stack trace: +/// catch (Exception ex) +/// { +/// await _systemLog.LogAsync(SystemLogType.Error, "Cleanup failed", ex.ToString()); +/// } +/// +/// +public interface ISystemLogService +{ + /// + /// Writes a log entry to the SystemLogs table. + /// + /// The severity level of the log entry. + /// A short summary of what happened. + /// Optional extended information such as a stack trace. + /// Cancellation token. + Task LogAsync(SystemLogType type, string description, string? details = null, + CancellationToken cancellationToken = default); +} diff --git a/Services/SystemLogService.cs b/Services/SystemLogService.cs new file mode 100644 index 0000000..e8ba38e --- /dev/null +++ b/Services/SystemLogService.cs @@ -0,0 +1,31 @@ +using TransitAnalyticsAPI.Models.Entities; +using TransitAnalyticsAPI.Persistence; + +namespace TransitAnalyticsAPI.Services; + +public class SystemLogService : ISystemLogService +{ + private static readonly string Source = typeof(T).Name; + + private readonly AppDbContext _appDbContext; + + public SystemLogService(AppDbContext appDbContext) + { + _appDbContext = appDbContext; + } + + public async Task LogAsync(SystemLogType type, string description, string? details = null, + CancellationToken cancellationToken = default) + { + _appDbContext.SystemLogs.Add(new SystemLog + { + CreatedAtUtc = DateTime.UtcNow, + Type = type, + Source = Source, + Description = description, + Details = details + }); + + await _appDbContext.SaveChangesAsync(cancellationToken); + } +} diff --git a/Services/VehicleRetentionService.cs b/Services/VehicleRetentionService.cs index 99af607..216a3af 100644 --- a/Services/VehicleRetentionService.cs +++ b/Services/VehicleRetentionService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using TransitAnalyticsAPI.Configuration; +using TransitAnalyticsAPI.Models.Entities; using TransitAnalyticsAPI.Persistence; namespace TransitAnalyticsAPI.Services; @@ -9,19 +10,29 @@ public class VehicleRetentionService : IVehicleRetentionService { private readonly AppDbContext _appDbContext; private readonly TimeSpan _historyRetention; + private readonly ISystemLogService _systemLog; public VehicleRetentionService( AppDbContext appDbContext, - IOptions vehicleOptions) + IOptions vehicleOptions, + ISystemLogService systemLogService) { _appDbContext = appDbContext; _historyRetention = TimeSpan.FromDays(Math.Max(1, vehicleOptions.Value.HistoryRetentionDays)); + _systemLog = systemLogService; } public async Task DeleteExpiredAsync(CancellationToken cancellationToken = default) { var cutoffUtc = DateTime.UtcNow - _historyRetention; + var dbSize = await _appDbContext.Database + .SqlQueryRaw("SELECT pg_size_pretty(pg_database_size(current_database())) AS \"Value\"") + .FirstAsync(cancellationToken); + + await _systemLog.LogAsync(SystemLogType.Info, "Started vehicle retention cleanup", + $"Current db size: {dbSize}.", cancellationToken); + return await _appDbContext.VehiclePositions .Where(vehiclePosition => vehiclePosition.RecordedAtUtc < cutoffUtc) .ExecuteDeleteAsync(cancellationToken);