diff --git a/Data/Migrations/20251209141933_AllowNullableGroundStationIdInFlightPlans.Designer.cs b/Data/Migrations/20251209141933_AllowNullableGroundStationIdInFlightPlans.Designer.cs
new file mode 100644
index 0000000..4fcbb3c
--- /dev/null
+++ b/Data/Migrations/20251209141933_AllowNullableGroundStationIdInFlightPlans.Designer.cs
@@ -0,0 +1,561 @@
+//
+using System;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using SatOps.Data;
+
+#nullable disable
+
+namespace SatOps.Data.migrations
+{
+ [DbContext(typeof(SatOpsDbContext))]
+ [Migration("20251209141933_AllowNullableGroundStationIdInFlightPlans")]
+ partial class AllowNullableGroundStationIdInFlightPlans
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("SatOps.Modules.FlightPlan.FlightPlan", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApprovalDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ApprovedById")
+ .HasColumnType("integer");
+
+ b.Property("Commands")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("timezone('utc', now())");
+
+ b.Property("CreatedById")
+ .HasColumnType("integer");
+
+ b.Property("FailureReason")
+ .HasColumnType("text");
+
+ b.Property("GroundStationId")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PreviousPlanId")
+ .HasColumnType("integer");
+
+ b.Property("SatelliteId")
+ .HasColumnType("integer");
+
+ b.Property("ScheduledAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("timezone('utc', now())");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApprovedById");
+
+ b.HasIndex("CreatedById");
+
+ b.HasIndex("GroundStationId");
+
+ b.HasIndex("PreviousPlanId");
+
+ b.HasIndex("SatelliteId");
+
+ b.HasIndex("Status");
+
+ b.ToTable("flight_plans", (string)null);
+ });
+
+ modelBuilder.Entity("SatOps.Modules.GroundStationLink.ImageData", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CaptureTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("FileSize")
+ .HasColumnType("bigint");
+
+ b.Property("FlightPlanId")
+ .HasColumnType("integer");
+
+ b.Property("GroundStationId")
+ .HasColumnType("integer");
+
+ b.Property("ImageHeight")
+ .HasColumnType("integer");
+
+ b.Property("ImageWidth")
+ .HasColumnType("integer");
+
+ b.Property("Latitude")
+ .HasPrecision(9, 6)
+ .HasColumnType("double precision");
+
+ b.Property("Longitude")
+ .HasPrecision(9, 6)
+ .HasColumnType("double precision");
+
+ b.Property("Metadata")
+ .HasColumnType("jsonb");
+
+ b.Property("ReceivedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("timezone('utc', now())");
+
+ b.Property("S3ObjectPath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)");
+
+ b.Property("SatelliteId")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CaptureTime");
+
+ b.HasIndex("FlightPlanId");
+
+ b.HasIndex("GroundStationId");
+
+ b.HasIndex("SatelliteId");
+
+ b.HasIndex("Latitude", "Longitude");
+
+ b.ToTable("image_data", (string)null);
+ });
+
+ modelBuilder.Entity("SatOps.Modules.Groundstation.GroundStation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApiKeyHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ApplicationId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("timezone('utc', now())");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicationId")
+ .IsUnique();
+
+ b.ToTable("ground_stations", (string)null);
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ ApiKeyHash = "",
+ ApplicationId = new Guid("c4bd6c61-8e11-43cf-868a-fdd0a4be81b2"),
+ CreatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9380),
+ Name = "Aarhus",
+ UpdatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9380)
+ });
+ });
+
+ modelBuilder.Entity("SatOps.Modules.Overpass.Entity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DurationSeconds")
+ .HasColumnType("integer");
+
+ b.Property("EndAzimuth")
+ .HasColumnType("double precision");
+
+ b.Property("EndTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FlightPlanId")
+ .HasColumnType("integer");
+
+ b.Property("GroundStationId")
+ .HasColumnType("integer");
+
+ b.Property("MaxElevation")
+ .HasColumnType("double precision");
+
+ b.Property("MaxElevationTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SatelliteId")
+ .HasColumnType("integer");
+
+ b.Property("StartAzimuth")
+ .HasColumnType("double precision");
+
+ b.Property("StartTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TleLine1")
+ .HasColumnType("text");
+
+ b.Property("TleLine2")
+ .HasColumnType("text");
+
+ b.Property("TleUpdateTime")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FlightPlanId")
+ .IsUnique();
+
+ b.HasIndex("GroundStationId");
+
+ b.HasIndex("SatelliteId", "GroundStationId", "StartTime");
+
+ b.ToTable("overpasses", (string)null);
+ });
+
+ modelBuilder.Entity("SatOps.Modules.Satellite.Satellite", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("timezone('utc', now())");
+
+ b.Property("LastUpdate")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("timezone('utc', now())");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("NoradId")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("TleLine1")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TleLine2")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("NoradId")
+ .IsUnique();
+
+ b.HasIndex("Status");
+
+ b.ToTable("satellites", (string)null);
+
+ b.HasData(
+ new
+ {
+ Id = 1,
+ CreatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
+ LastUpdate = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
+ Name = "International Space Station (ISS)",
+ NoradId = 25544,
+ Status = 0,
+ TleLine1 = "1 25544U 98067A 23256.90616898 .00020137 00000-0 35438-3 0 9992",
+ TleLine2 = "2 25544 51.6416 339.0970 0003835 48.3825 73.2709 15.50030022414673"
+ },
+ new
+ {
+ Id = 2,
+ CreatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
+ LastUpdate = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
+ Name = "SENTINEL-2C",
+ NoradId = 60989,
+ Status = 0,
+ TleLine1 = "1 60989U 24157A 25270.79510520 .00000303 00000-0 13232-3 0 9996",
+ TleLine2 = "2 60989 98.5675 344.4033 0001006 86.9003 273.2295 14.30815465 55465"
+ });
+ });
+
+ modelBuilder.Entity("SatOps.Modules.User.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Auth0UserId")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("timezone('utc', now())");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Role")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("timezone('utc', now())");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Role");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("SatOps.Modules.FlightPlan.FlightPlan", b =>
+ {
+ b.HasOne("SatOps.Modules.User.User", "ApprovedBy")
+ .WithMany("ApprovedFlightPlans")
+ .HasForeignKey("ApprovedById")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("SatOps.Modules.User.User", "CreatedBy")
+ .WithMany("CreatedFlightPlans")
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("SatOps.Modules.Groundstation.GroundStation", "GroundStation")
+ .WithMany("FlightPlans")
+ .HasForeignKey("GroundStationId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("SatOps.Modules.FlightPlan.FlightPlan", "PreviousPlan")
+ .WithMany()
+ .HasForeignKey("PreviousPlanId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("SatOps.Modules.Satellite.Satellite", "Satellite")
+ .WithMany("FlightPlans")
+ .HasForeignKey("SatelliteId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("ApprovedBy");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("GroundStation");
+
+ b.Navigation("PreviousPlan");
+
+ b.Navigation("Satellite");
+ });
+
+ modelBuilder.Entity("SatOps.Modules.GroundStationLink.ImageData", b =>
+ {
+ b.HasOne("SatOps.Modules.FlightPlan.FlightPlan", "FlightPlan")
+ .WithMany()
+ .HasForeignKey("FlightPlanId")
+ .OnDelete(DeleteBehavior.SetNull);
+
+ b.HasOne("SatOps.Modules.Groundstation.GroundStation", "GroundStation")
+ .WithMany("Images")
+ .HasForeignKey("GroundStationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("SatOps.Modules.Satellite.Satellite", "Satellite")
+ .WithMany("Images")
+ .HasForeignKey("SatelliteId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("FlightPlan");
+
+ b.Navigation("GroundStation");
+
+ b.Navigation("Satellite");
+ });
+
+ modelBuilder.Entity("SatOps.Modules.Groundstation.GroundStation", b =>
+ {
+ b.OwnsOne("SatOps.Modules.Groundstation.Location", "Location", b1 =>
+ {
+ b1.Property("GroundStationId")
+ .HasColumnType("integer");
+
+ b1.Property("Altitude")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("double precision")
+ .HasDefaultValue(0.0)
+ .HasColumnName("altitude");
+
+ b1.Property("Latitude")
+ .HasColumnType("double precision")
+ .HasColumnName("latitude");
+
+ b1.Property("Longitude")
+ .HasColumnType("double precision")
+ .HasColumnName("longitude");
+
+ b1.HasKey("GroundStationId");
+
+ b1.ToTable("ground_stations");
+
+ b1.WithOwner()
+ .HasForeignKey("GroundStationId");
+
+ b1.HasData(
+ new
+ {
+ GroundStationId = 1,
+ Altitude = 62.0,
+ Latitude = 56.171972897990663,
+ Longitude = 10.191659216036516
+ });
+ });
+
+ b.Navigation("Location")
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("SatOps.Modules.Overpass.Entity", b =>
+ {
+ b.HasOne("SatOps.Modules.FlightPlan.FlightPlan", "FlightPlan")
+ .WithOne("Overpass")
+ .HasForeignKey("SatOps.Modules.Overpass.Entity", "FlightPlanId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("SatOps.Modules.Groundstation.GroundStation", "GroundStation")
+ .WithMany("Overpasses")
+ .HasForeignKey("GroundStationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("SatOps.Modules.Satellite.Satellite", "Satellite")
+ .WithMany("Overpasses")
+ .HasForeignKey("SatelliteId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("FlightPlan");
+
+ b.Navigation("GroundStation");
+
+ b.Navigation("Satellite");
+ });
+
+ modelBuilder.Entity("SatOps.Modules.FlightPlan.FlightPlan", b =>
+ {
+ b.Navigation("Overpass");
+ });
+
+ modelBuilder.Entity("SatOps.Modules.Groundstation.GroundStation", b =>
+ {
+ b.Navigation("FlightPlans");
+
+ b.Navigation("Images");
+
+ b.Navigation("Overpasses");
+ });
+
+ modelBuilder.Entity("SatOps.Modules.Satellite.Satellite", b =>
+ {
+ b.Navigation("FlightPlans");
+
+ b.Navigation("Images");
+
+ b.Navigation("Overpasses");
+ });
+
+ modelBuilder.Entity("SatOps.Modules.User.User", b =>
+ {
+ b.Navigation("ApprovedFlightPlans");
+
+ b.Navigation("CreatedFlightPlans");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Data/Migrations/20251209141933_AllowNullableGroundStationIdInFlightPlans.cs b/Data/Migrations/20251209141933_AllowNullableGroundStationIdInFlightPlans.cs
new file mode 100644
index 0000000..6708891
--- /dev/null
+++ b/Data/Migrations/20251209141933_AllowNullableGroundStationIdInFlightPlans.cs
@@ -0,0 +1,103 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace SatOps.Data.migrations
+{
+ ///
+ public partial class AllowNullableGroundStationIdInFlightPlans : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_flight_plans_ground_stations_GroundStationId",
+ table: "flight_plans");
+
+ migrationBuilder.AlterColumn(
+ name: "GroundStationId",
+ table: "flight_plans",
+ type: "integer",
+ nullable: true,
+ oldClrType: typeof(int),
+ oldType: "integer");
+
+ migrationBuilder.UpdateData(
+ table: "ground_stations",
+ keyColumn: "Id",
+ keyValue: 1,
+ columns: new[] { "ApplicationId", "CreatedAt", "UpdatedAt" },
+ values: new object[] { new Guid("c4bd6c61-8e11-43cf-868a-fdd0a4be81b2"), new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9380), new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9380) });
+
+ migrationBuilder.UpdateData(
+ table: "satellites",
+ keyColumn: "Id",
+ keyValue: 1,
+ columns: new[] { "CreatedAt", "LastUpdate" },
+ values: new object[] { new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710), new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710) });
+
+ migrationBuilder.UpdateData(
+ table: "satellites",
+ keyColumn: "Id",
+ keyValue: 2,
+ columns: new[] { "CreatedAt", "LastUpdate" },
+ values: new object[] { new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710), new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710) });
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_flight_plans_ground_stations_GroundStationId",
+ table: "flight_plans",
+ column: "GroundStationId",
+ principalTable: "ground_stations",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.SetNull);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "FK_flight_plans_ground_stations_GroundStationId",
+ table: "flight_plans");
+
+ migrationBuilder.AlterColumn(
+ name: "GroundStationId",
+ table: "flight_plans",
+ type: "integer",
+ nullable: false,
+ defaultValue: 0,
+ oldClrType: typeof(int),
+ oldType: "integer",
+ oldNullable: true);
+
+ migrationBuilder.UpdateData(
+ table: "ground_stations",
+ keyColumn: "Id",
+ keyValue: 1,
+ columns: new[] { "ApplicationId", "CreatedAt", "UpdatedAt" },
+ values: new object[] { new Guid("eecf3fa7-de5b-41e0-83ac-ce6d14cd9b86"), new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5207), new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5208) });
+
+ migrationBuilder.UpdateData(
+ table: "satellites",
+ keyColumn: "Id",
+ keyValue: 1,
+ columns: new[] { "CreatedAt", "LastUpdate" },
+ values: new object[] { new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5387), new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5388) });
+
+ migrationBuilder.UpdateData(
+ table: "satellites",
+ keyColumn: "Id",
+ keyValue: 2,
+ columns: new[] { "CreatedAt", "LastUpdate" },
+ values: new object[] { new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5391), new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5391) });
+
+ migrationBuilder.AddForeignKey(
+ name: "FK_flight_plans_ground_stations_GroundStationId",
+ table: "flight_plans",
+ column: "GroundStationId",
+ principalTable: "ground_stations",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Restrict);
+ }
+ }
+}
diff --git a/Data/Migrations/SatOpsDbContextModelSnapshot.cs b/Data/Migrations/SatOpsDbContextModelSnapshot.cs
index b141fd7..6fd0124 100644
--- a/Data/Migrations/SatOpsDbContextModelSnapshot.cs
+++ b/Data/Migrations/SatOpsDbContextModelSnapshot.cs
@@ -52,7 +52,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property("FailureReason")
.HasColumnType("text");
- b.Property("GroundStationId")
+ b.Property("GroundStationId")
.HasColumnType("integer");
b.Property("Name")
@@ -207,10 +207,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{
Id = 1,
ApiKeyHash = "",
- ApplicationId = new Guid("eecf3fa7-de5b-41e0-83ac-ce6d14cd9b86"),
- CreatedAt = new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5207),
+ ApplicationId = new Guid("c4bd6c61-8e11-43cf-868a-fdd0a4be81b2"),
+ CreatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9380),
Name = "Aarhus",
- UpdatedAt = new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5208)
+ UpdatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9380)
});
});
@@ -322,8 +322,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
new
{
Id = 1,
- CreatedAt = new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5387),
- LastUpdate = new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5388),
+ CreatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
+ LastUpdate = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
Name = "International Space Station (ISS)",
NoradId = 25544,
Status = 0,
@@ -333,8 +333,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
new
{
Id = 2,
- CreatedAt = new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5391),
- LastUpdate = new DateTime(2025, 11, 12, 20, 16, 41, 276, DateTimeKind.Utc).AddTicks(5391),
+ CreatedAt = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
+ LastUpdate = new DateTime(2025, 12, 9, 14, 19, 32, 968, DateTimeKind.Utc).AddTicks(9710),
Name = "SENTINEL-2C",
NoradId = 60989,
Status = 0,
@@ -404,8 +404,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasOne("SatOps.Modules.Groundstation.GroundStation", "GroundStation")
.WithMany("FlightPlans")
.HasForeignKey("GroundStationId")
- .OnDelete(DeleteBehavior.Restrict)
- .IsRequired();
+ .OnDelete(DeleteBehavior.SetNull);
b.HasOne("SatOps.Modules.FlightPlan.FlightPlan", "PreviousPlan")
.WithMany()
diff --git a/Data/SatOpsDbContext.cs b/Data/SatOpsDbContext.cs
index 5601dba..4a78152 100644
--- a/Data/SatOpsDbContext.cs
+++ b/Data/SatOpsDbContext.cs
@@ -108,7 +108,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
entity.HasOne(fp => fp.GroundStation)
.WithMany(gs => gs.FlightPlans)
.HasForeignKey(fp => fp.GroundStationId)
- .OnDelete(DeleteBehavior.Restrict);
+ .OnDelete(DeleteBehavior.SetNull);
entity.HasOne(fp => fp.Satellite)
.WithMany(s => s.FlightPlans)
diff --git a/Modules/FlightPlan/Controller.cs b/Modules/FlightPlan/Controller.cs
index a97fafd..4ae8326 100644
--- a/Modules/FlightPlan/Controller.cs
+++ b/Modules/FlightPlan/Controller.cs
@@ -56,7 +56,13 @@ public async Task> Create([FromBody] CreateFlightPla
catch (ArgumentException ex)
{
logger.LogWarning(ex, "Invalid flight plan JSON: {Message}", ex.Message);
- return BadRequest(new { detail = ex.Message });
+ return BadRequest(new ProblemDetails
+ {
+ Title = "Invalid Flight Plan",
+ Detail = ex.Message,
+ Status = StatusCodes.Status400BadRequest,
+ Instance = HttpContext.Request.Path
+ });
}
}
@@ -77,10 +83,12 @@ public async Task> Update(
var newVersion = await service.CreateNewVersionAsync(id, input);
if (newVersion == null)
{
- return BadRequest(new
+ return BadRequest(new ProblemDetails
{
- detail = "Could not update the flight plan. " +
- "It may not be in an updateable state."
+ Title = "Update Failed",
+ Detail = "Could not update the flight plan. It may not be in an updateable state.",
+ Status = StatusCodes.Status400BadRequest,
+ Instance = HttpContext.Request.Path
});
}
return Ok(newVersion.ToDto());
@@ -88,7 +96,13 @@ public async Task> Update(
catch (ArgumentException ex)
{
logger.LogWarning(ex, "Invalid flight plan update: {Message}", ex.Message);
- return BadRequest(new { detail = ex.Message });
+ return BadRequest(new ProblemDetails
+ {
+ Title = "Invalid Flight Plan Update",
+ Detail = ex.Message,
+ Status = StatusCodes.Status400BadRequest,
+ Instance = HttpContext.Request.Path
+ });
}
}
@@ -105,16 +119,25 @@ public async Task Approve(int id, [FromBody] ApproveFlightPlanDto
{
if (input.Status != "APPROVED" && input.Status != "REJECTED")
{
- return BadRequest(new
+ return BadRequest(new ProblemDetails
{
- detail = "Invalid status. Must be APPROVED or REJECTED"
+ Title = "Invalid Status",
+ Detail = "Invalid status. Must be APPROVED or REJECTED",
+ Status = StatusCodes.Status400BadRequest,
+ Instance = HttpContext.Request.Path
});
}
var (success, message) = await service.ApproveOrRejectAsync(id, input.Status);
if (!success)
{
- return Conflict(new { detail = message });
+ return Conflict(new ProblemDetails
+ {
+ Title = "Approval Conflict",
+ Detail = message,
+ Status = StatusCodes.Status409Conflict,
+ Instance = HttpContext.Request.Path
+ });
}
return Ok(new { success = true, message });
@@ -135,7 +158,13 @@ public async Task AssignOverpass(
var (success, message) = await service.AssignOverpassAsync(id, input);
if (!success)
{
- return Conflict(new { detail = message });
+ return Conflict(new ProblemDetails
+ {
+ Title = "Assignment Conflict",
+ Detail = message,
+ Status = StatusCodes.Status409Conflict,
+ Instance = HttpContext.Request.Path
+ });
}
return Ok(new { success = true, message });
@@ -161,7 +190,13 @@ public async Task>> CompileFlightPlan(int id)
}
catch (InvalidOperationException ex)
{
- return BadRequest(new { detail = ex.Message });
+ return BadRequest(new ProblemDetails
+ {
+ Title = "Compilation Failed",
+ Detail = ex.Message,
+ Status = StatusCodes.Status400BadRequest,
+ Instance = HttpContext.Request.Path
+ });
}
}
diff --git a/Modules/FlightPlan/Dto.cs b/Modules/FlightPlan/Dto.cs
index 5069631..5fddbdb 100644
--- a/Modules/FlightPlan/Dto.cs
+++ b/Modules/FlightPlan/Dto.cs
@@ -6,7 +6,7 @@ public class FlightPlanDto
{
public int Id { get; set; }
public int? PreviousPlanId { get; set; }
- public int GsId { get; set; }
+ public int? GsId { get; set; }
public int SatId { get; set; }
public int? OverpassId { get; set; }
diff --git a/Modules/FlightPlan/Entity.cs b/Modules/FlightPlan/Entity.cs
index 74f284f..8267f4d 100644
--- a/Modules/FlightPlan/Entity.cs
+++ b/Modules/FlightPlan/Entity.cs
@@ -21,7 +21,7 @@ public class FlightPlan
public string Name { get; set; } = string.Empty;
// --- Foreign Key Properties ---
- public int GroundStationId { get; set; }
+ public int? GroundStationId { get; set; }
public int SatelliteId { get; set; }
public int? PreviousPlanId { get; set; }
public int CreatedById { get; set; }
@@ -29,7 +29,7 @@ public class FlightPlan
// --- Navigation Properties ---
[ForeignKey(nameof(GroundStationId))]
- public virtual Groundstation.GroundStation GroundStation { get; set; } = null!;
+ public virtual Groundstation.GroundStation? GroundStation { get; set; }
[ForeignKey(nameof(SatelliteId))]
public virtual Satellite.Satellite Satellite { get; set; } = null!;
diff --git a/Modules/FlightPlan/SchedulerService.cs b/Modules/FlightPlan/SchedulerService.cs
index 71cbbc2..b966cdc 100644
--- a/Modules/FlightPlan/SchedulerService.cs
+++ b/Modules/FlightPlan/SchedulerService.cs
@@ -25,11 +25,18 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
foreach (var plan in plansToSend)
{
- if (gatewayService.IsGroundStationConnected(plan.GroundStationId))
+ // Skip plans that don't have a ground station assigned
+ if (!plan.GroundStationId.HasValue)
+ {
+ logger.LogWarning("Flight plan {PlanId} has no assigned ground station. Skipping.", plan.Id);
+ continue;
+ }
+
+ if (gatewayService.IsGroundStationConnected(plan.GroundStationId.Value))
{
try
{
- logger.LogInformation("Found plan {PlanId} for GS {GSId}, scheduled for {ScheduledTime}. Preparing for transmission.", plan.Id, plan.GroundStationId, plan.ScheduledAt);
+ logger.LogInformation("Found plan {PlanId} for GS {GSId}, scheduled for {ScheduledTime}. Preparing for transmission.", plan.Id, plan.GroundStationId.Value, plan.ScheduledAt);
var satellite = await satelliteService.GetAsync(plan.SatelliteId);
if (satellite == null)
@@ -45,7 +52,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
var cshScript = await flightPlanService.CompileFlightPlanToCshAsync(plan.Id);
await gatewayService.SendScheduledCommand(
- plan.GroundStationId,
+ plan.GroundStationId.Value,
plan.SatelliteId,
satellite.Name,
plan.Id,
@@ -55,18 +62,18 @@ await gatewayService.SendScheduledCommand(
// Update status to prevent re-sending.
await flightPlanService.UpdateFlightPlanStatusAsync(plan.Id, FlightPlanStatus.Transmitted);
- logger.LogInformation("Successfully transmitted flight plan {PlanId} to Ground Station {GSId}.", plan.Id, plan.GroundStationId);
+ logger.LogInformation("Successfully transmitted flight plan {PlanId} to Ground Station {GSId}.", plan.Id, plan.GroundStationId.Value);
}
catch (Exception ex)
{
var reason = $"An exception occurred during transmission: {ex.Message}";
- logger.LogError(ex, "Failed to transmit flight plan {PlanId} to GS {GSId}", plan.Id, plan.GroundStationId);
+ logger.LogError(ex, "Failed to transmit flight plan {PlanId} to GS {GSId}", plan.Id, plan.GroundStationId.Value);
await flightPlanService.UpdateFlightPlanStatusAsync(plan.Id, FlightPlanStatus.Failed, reason);
}
}
else
{
- var reason = $"Ground Station {plan.GroundStationId} is not connected.";
+ var reason = $"Ground Station {plan.GroundStationId.Value} is not connected.";
logger.LogWarning("Cannot transmit flight plan {PlanId}: {Reason}", plan.Id, reason);
if (plan.ScheduledAt.HasValue && (plan.ScheduledAt.Value - DateTime.UtcNow) < TimeSpan.FromMinutes(1))
diff --git a/Modules/FlightPlan/Service.cs b/Modules/FlightPlan/Service.cs
index 64621c2..b8d0bb8 100644
--- a/Modules/FlightPlan/Service.cs
+++ b/Modules/FlightPlan/Service.cs
@@ -159,6 +159,11 @@ public async Task CreateAsync(CreateFlightPlanDto createDto)
return (false, $"Flight plan must be in APPROVED status to assign an overpass. Current: {flightPlan.Status}");
}
+ if (!flightPlan.GroundStationId.HasValue)
+ {
+ return (false, "Flight plan has no assigned ground station.");
+ }
+
var satellite = await satelliteService.GetAsync(flightPlan.SatelliteId);
if (satellite == null) return (false, "Associated satellite not found.");
@@ -169,7 +174,7 @@ public async Task CreateAsync(CreateFlightPlanDto createDto)
var availableOverpasses = await overpassService.CalculateOverpassesAsync(new OverpassWindowsCalculationRequestDto
{
SatelliteId = flightPlan.SatelliteId,
- GroundStationId = flightPlan.GroundStationId,
+ GroundStationId = flightPlan.GroundStationId.Value,
StartTime = expandedStartTime,
EndTime = expandedEndTime,
});
diff --git a/Modules/Overpass/Controller.cs b/Modules/Overpass/Controller.cs
index 1a136fb..4ec9de1 100644
--- a/Modules/Overpass/Controller.cs
+++ b/Modules/Overpass/Controller.cs
@@ -61,15 +61,33 @@ public async Task>> GetOverpassWindows(
}
catch (ArgumentException ex)
{
- return BadRequest(ex.Message);
+ return BadRequest(new ProblemDetails
+ {
+ Title = "Invalid Request",
+ Detail = ex.Message,
+ Status = StatusCodes.Status400BadRequest,
+ Instance = HttpContext.Request.Path
+ });
}
catch (InvalidOperationException ex)
{
- return StatusCode(500, ex.Message);
+ return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
+ {
+ Title = "Internal Server Error",
+ Detail = ex.Message,
+ Status = StatusCodes.Status500InternalServerError,
+ Instance = HttpContext.Request.Path
+ });
}
catch (Exception ex)
{
- return StatusCode(500, $"An unexpected error occurred: {ex.Message}");
+ return StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetails
+ {
+ Title = "Unexpected Error",
+ Detail = $"An unexpected error occurred: {ex.Message}",
+ Status = StatusCodes.Status500InternalServerError,
+ Instance = HttpContext.Request.Path
+ });
}
}
}
diff --git a/diagrams/commandClassDiagram.mermaid b/diagrams/commandClassDiagram.mermaid
index ececadb..6cafbd8 100644
--- a/diagrams/commandClassDiagram.mermaid
+++ b/diagrams/commandClassDiagram.mermaid
@@ -4,24 +4,33 @@ classDiagram
+string CommandType*
+DateTime? ExecutionTime
+bool RequiresExecutionTimeCalculation
- +Validate() IEnumerable~ValidationResult~
+ +Validate() IEnumerable~ValidationResult~*
+CompileToCsh()* Task~List~string~~
}
class TriggerCaptureCommand {
+CommandType = "TRIGGER_CAPTURE"
+RequiresExecutionTimeCalculation = true
+ -int CameraControllerNode = 5422
+CaptureLocation? CaptureLocation
+CameraSettings? CameraSettings
+ +Validate() IEnumerable~ValidationResult~
+CompileToCsh() Task~List~string~~
}
class TriggerPipelineCommand {
+CommandType = "TRIGGER_PIPELINE"
+ -int DippNode = 5423
+int? Mode
+CompileToCsh() Task~List~string~~
}
+ class ConfigureSOMCommand {
+ +CommandType = "CONFIGURE_SOM"
+ -int AppSysNode = 5421
+ +CompileToCsh() Task~List~string~~
+ }
+
class CaptureLocation {
+double Latitude
+double Longitude
@@ -40,13 +49,14 @@ classDiagram
class CameraType {
<>
- VMB
- IR
- Test
+ VMB = 0
+ IR = 1
+ Test = 2
}
Command <|-- TriggerCaptureCommand : inherits
Command <|-- TriggerPipelineCommand : inherits
+ Command <|-- ConfigureSOMCommand : inherits
TriggerCaptureCommand --> CaptureLocation : uses
TriggerCaptureCommand --> CameraSettings : uses
- CameraSettings --> CameraType : uses
+ CameraSettings --> CameraType : uses
\ No newline at end of file
diff --git a/diagrams/overpassCalculation.mermaid b/diagrams/overpassCalculation.mermaid
new file mode 100644
index 0000000..8c1c6ed
--- /dev/null
+++ b/diagrams/overpassCalculation.mermaid
@@ -0,0 +1,65 @@
+sequenceDiagram
+ participant Client
+ participant CalculateOverpasses
+ participant Repository
+ participant SatelliteService
+ participant GroundStationService
+ participant SGPdotNET
+ participant MergeAndEnrich
+
+ Client->>CalculateOverpasses: CalculateOverpassesAsync(request)
+
+ Note over CalculateOverpasses: Check for stored overpasses
+ CalculateOverpasses->>Repository: FindStoredOverpassesInTimeRange()
+ Repository-->>CalculateOverpasses: storedOverpasses
+
+ Note over CalculateOverpasses: Fetch satellite data
+ CalculateOverpasses->>SatelliteService: GetAsync(satelliteId)
+ SatelliteService-->>CalculateOverpasses: satellite (with TLE data)
+
+ Note over CalculateOverpasses: Fetch ground station data
+ CalculateOverpasses->>GroundStationService: GetAsync(groundStationId)
+ GroundStationService-->>CalculateOverpasses: groundStation (with location)
+
+ Note over CalculateOverpasses: Initialize SGP4 propagator
+ CalculateOverpasses->>SGPdotNET: new Tle(name, line1, line2)
+ SGPdotNET-->>CalculateOverpasses: tle
+ CalculateOverpasses->>SGPdotNET: new Satellite(tle)
+ SGPdotNET-->>CalculateOverpasses: sat
+ CalculateOverpasses->>SGPdotNET: new GroundStation(location)
+ SGPdotNET-->>CalculateOverpasses: groundStation
+
+ Note over CalculateOverpasses: Time-stepping loop
+ loop For each time step (1 minute)
+ CalculateOverpasses->>SGPdotNET: groundStation.Observe(sat, currentTime)
+ SGPdotNET-->>CalculateOverpasses: observation (elevation, azimuth)
+
+ alt Elevation >= MinimumElevation && not in overpass
+ Note over CalculateOverpasses: Start new overpass
Record start time, azimuth
Track max elevation
+ else Elevation >= MinimumElevation && in overpass
+ Note over CalculateOverpasses: Continue overpass
Update max elevation if higher
+ else Elevation < MinimumElevation && in overpass
+ Note over CalculateOverpasses: End overpass
Calculate duration
Check minimum duration filter
+ alt Duration meets minimum
+ Note over CalculateOverpasses: Add to overpassWindows list
+ end
+ alt MaxResults reached
+ Note over CalculateOverpasses: Break loop
+ end
+ end
+ end
+
+ Note over CalculateOverpasses: Merge calculated with stored data
+ CalculateOverpasses->>MergeAndEnrich: MergeAndEnrichOverpasses(calculated, stored)
+
+ loop For each calculated overpass
+ MergeAndEnrich->>MergeAndEnrich: Find matching stored overpass
(tolerance: 10 minutes)
+ alt Match found
+ MergeAndEnrich->>Repository: GetAssociatedFlightPlanAsync(overpassId)
+ Repository-->>MergeAndEnrich: flightPlan
+ Note over MergeAndEnrich: Enrich with flight plan details
+ end
+ end
+
+ MergeAndEnrich-->>CalculateOverpasses: mergedOverpasses (sorted by start time)
+ CalculateOverpasses-->>Client: List
diff --git a/tests/SatOps.Tests/FlightPlan/FlightPlanServiceTests.cs b/tests/SatOps.Tests/FlightPlan/FlightPlanServiceTests.cs
index 1d6a80e..a7ef651 100644
--- a/tests/SatOps.Tests/FlightPlan/FlightPlanServiceTests.cs
+++ b/tests/SatOps.Tests/FlightPlan/FlightPlanServiceTests.cs
@@ -248,6 +248,7 @@ public async Task AssignOverpassAsync_WhenConflictingPlanExists_ReturnsConflictE
Id = planId,
Status = FlightPlanStatus.Approved,
SatelliteId = satId,
+ GroundStationId = 1,
Name = "New Plan"
};