refactor: rename project from Flights to Missions and update related components
ci/woodpecker/push/build-arm Pipeline was successful

This commit transitions the project from Azaion.Flights to Azaion.Missions, updating namespaces, DTOs, services, and database entities accordingly. The Docker configuration and entry points have been modified to reflect the new project structure. Additionally, the README and documentation have been updated to clarify the ongoing renaming process and its implications. All references to flights have been replaced with missions, ensuring consistency across the codebase.
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-15 04:35:49 +03:00
parent 4f226e91d5
commit 2840ccb9b6
51 changed files with 381 additions and 352 deletions
+2 -2
View File
@@ -25,7 +25,7 @@ steps:
--label org.opencontainers.image.revision=$CI_COMMIT_SHA \
--label org.opencontainers.image.created=$BUILD_DATE \
--label org.opencontainers.image.source=$CI_REPO_URL \
-t $REGISTRY_HOST/azaion/flights:$TAG .
- docker push $REGISTRY_HOST/azaion/flights:$TAG
-t $REGISTRY_HOST/azaion/missions:$TAG .
- docker push $REGISTRY_HOST/azaion/missions:$TAG
volumes:
- /var/run/docker.sock:/var/run/docker.sock
+3 -4
View File
@@ -1,9 +1,9 @@
using Azaion.Flights.Infrastructure;
using Azaion.Missions.Infrastructure;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Tokens;
namespace Azaion.Flights.Auth;
namespace Azaion.Missions.Auth;
public static class JwtExtensions
{
@@ -85,8 +85,7 @@ public static class JwtExtensions
});
services.AddAuthorizationBuilder()
.AddPolicy("FL", p => p.RequireClaim("permissions", "FL"))
.AddPolicy("GPS", p => p.RequireClaim("permissions", "GPS"));
.AddPolicy("FL", p => p.RequireClaim("permissions", "FL"));
return services;
}
+17 -17
View File
@@ -1,47 +1,47 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Azaion.Flights.DTOs;
using Azaion.Flights.Services;
using Azaion.Missions.DTOs;
using Azaion.Missions.Services;
namespace Azaion.Flights.Controllers;
namespace Azaion.Missions.Controllers;
[ApiController]
[Route("flights")]
[Route("missions")]
[Authorize(Policy = "FL")]
public class FlightsController(FlightService flightService, WaypointService waypointService) : ControllerBase
public class MissionsController(MissionService missionService, WaypointService waypointService) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateFlightRequest request)
public async Task<IActionResult> Create([FromBody] CreateMissionRequest request)
{
var flight = await flightService.CreateFlight(request);
return Created($"/flights/{flight.Id}", flight);
var mission = await missionService.CreateMission(request);
return Created($"/missions/{mission.Id}", mission);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateFlightRequest request)
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateMissionRequest request)
{
var flight = await flightService.UpdateFlight(id, request);
return Ok(flight);
var mission = await missionService.UpdateMission(id, request);
return Ok(mission);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id)
{
var flight = await flightService.GetFlight(id);
return Ok(flight);
var mission = await missionService.GetMission(id);
return Ok(mission);
}
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] GetFlightsQuery query)
public async Task<IActionResult> GetAll([FromQuery] GetMissionsQuery query)
{
var result = await flightService.GetFlights(query);
var result = await missionService.GetMissions(query);
return Ok(result);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await flightService.DeleteFlight(id);
await missionService.DeleteMission(id);
return NoContent();
}
@@ -49,7 +49,7 @@ public class FlightsController(FlightService flightService, WaypointService wayp
public async Task<IActionResult> CreateWaypoint(Guid id, [FromBody] CreateWaypointRequest request)
{
var waypoint = await waypointService.CreateWaypoint(id, request);
return Created($"/flights/{id}/waypoints/{waypoint.Id}", waypoint);
return Created($"/missions/{id}/waypoints/{waypoint.Id}", waypoint);
}
[HttpPut("{id:guid}/waypoints/{waypointId:guid}")]
+18 -18
View File
@@ -1,54 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Azaion.Flights.DTOs;
using Azaion.Flights.Services;
using Azaion.Missions.DTOs;
using Azaion.Missions.Services;
namespace Azaion.Flights.Controllers;
namespace Azaion.Missions.Controllers;
[ApiController]
[Route("aircrafts")]
[Route("vehicles")]
[Authorize(Policy = "FL")]
public class AircraftsController(AircraftService aircraftService) : ControllerBase
public class VehiclesController(VehicleService vehicleService) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateAircraftRequest request)
public async Task<IActionResult> Create([FromBody] CreateVehicleRequest request)
{
var aircraft = await aircraftService.CreateAircraft(request);
return Created($"/aircrafts/{aircraft.Id}", aircraft);
var vehicle = await vehicleService.CreateVehicle(request);
return Created($"/vehicles/{vehicle.Id}", vehicle);
}
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateAircraftRequest request)
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateVehicleRequest request)
{
var aircraft = await aircraftService.UpdateAircraft(id, request);
return Ok(aircraft);
var vehicle = await vehicleService.UpdateVehicle(id, request);
return Ok(vehicle);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await aircraftService.DeleteAircraft(id);
await vehicleService.DeleteVehicle(id);
return NoContent();
}
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] GetAircraftsQuery query)
public async Task<IActionResult> GetAll([FromQuery] GetVehiclesQuery query)
{
var aircrafts = await aircraftService.GetAircrafts(query);
return Ok(aircrafts);
var vehicles = await vehicleService.GetVehicles(query);
return Ok(vehicles);
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id)
{
var aircraft = await aircraftService.GetAircraft(id);
return Ok(aircraft);
var vehicle = await vehicleService.GetVehicle(id);
return Ok(vehicle);
}
[HttpPatch("{id:guid}/default")]
public async Task<IActionResult> SetDefault(Guid id, [FromBody] SetDefaultRequest request)
{
await aircraftService.SetDefault(id, request);
await vehicleService.SetDefault(id, request);
return NoContent();
}
}
+3 -3
View File
@@ -1,8 +1,8 @@
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class CreateFlightRequest
public class CreateMissionRequest
{
public Guid AircraftId { get; set; }
public Guid VehicleId { get; set; }
public string Name { get; set; } = string.Empty;
public DateTime? CreatedDate { get; set; }
}
+4 -4
View File
@@ -1,10 +1,10 @@
using Azaion.Flights.Enums;
using Azaion.Missions.Enums;
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class CreateAircraftRequest
public class CreateVehicleRequest
{
public AircraftType Type { get; set; }
public VehicleType Type { get; set; }
public string Model { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public FuelType FuelType { get; set; }
+2 -2
View File
@@ -1,6 +1,6 @@
using Azaion.Flights.Enums;
using Azaion.Missions.Enums;
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class CreateWaypointRequest
{
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class ErrorResponse
{
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class GeoPoint
{
+2 -2
View File
@@ -1,6 +1,6 @@
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class GetFlightsQuery
public class GetMissionsQuery
{
public string? Name { get; set; }
public DateTime? FromDate { get; set; }
+2 -2
View File
@@ -1,6 +1,6 @@
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class GetAircraftsQuery
public class GetVehiclesQuery
{
public string? Name { get; set; }
public bool? IsDefault { get; set; }
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class PaginatedResponse<T>
{
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class SetDefaultRequest
{
+3 -3
View File
@@ -1,7 +1,7 @@
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class UpdateFlightRequest
public class UpdateMissionRequest
{
public string? Name { get; set; }
public Guid? AircraftId { get; set; }
public Guid? VehicleId { get; set; }
}
+4 -4
View File
@@ -1,10 +1,10 @@
using Azaion.Flights.Enums;
using Azaion.Missions.Enums;
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class UpdateAircraftRequest
public class UpdateVehicleRequest
{
public AircraftType? Type { get; set; }
public VehicleType? Type { get; set; }
public string? Model { get; set; }
public string? Name { get; set; }
public FuelType? FuelType { get; set; }
+2 -2
View File
@@ -1,6 +1,6 @@
using Azaion.Flights.Enums;
using Azaion.Missions.Enums;
namespace Azaion.Flights.DTOs;
namespace Azaion.Missions.DTOs;
public class UpdateWaypointRequest
{
+4 -6
View File
@@ -1,16 +1,14 @@
using LinqToDB;
using LinqToDB.Data;
using Azaion.Flights.Database.Entities;
using Azaion.Missions.Database.Entities;
namespace Azaion.Flights.Database;
namespace Azaion.Missions.Database;
public class AppDataConnection(DataOptions options) : DataConnection(options)
{
public ITable<Aircraft> Aircrafts => this.GetTable<Aircraft>();
public ITable<Flight> Flights => this.GetTable<Flight>();
public ITable<Vehicle> Vehicles => this.GetTable<Vehicle>();
public ITable<Mission> Missions => this.GetTable<Mission>();
public ITable<Waypoint> Waypoints => this.GetTable<Waypoint>();
public ITable<Orthophoto> Orthophotos => this.GetTable<Orthophoto>();
public ITable<GpsCorrection> GpsCorrections => this.GetTable<GpsCorrection>();
public ITable<MapObject> MapObjects => this.GetTable<MapObject>();
public ITable<Media> Media => this.GetTable<Media>();
public ITable<Annotation> Annotations => this.GetTable<Annotation>();
+65 -34
View File
@@ -1,16 +1,64 @@
using LinqToDB.Data;
namespace Azaion.Flights.Database;
namespace Azaion.Missions.Database;
// Forward-only migrator. Two SQL blocks run on every container start, in order:
//
// 1. RenameAndDropSql -- idempotent ALTERs that bring a legacy `flights`-era
// schema up to the renamed `missions` schema, plus DROPs for the
// GPS-Denied tables (Jira AZ-EPIC B7 / B9). On a fresh DB or an
// already-migrated DB this block is a no-op (every statement guards on
// IF EXISTS / column existence).
//
// 2. InitSql -- CREATE TABLE IF NOT EXISTS for the post-migration schema.
// On a legacy DB the renames in (1) leave tables already present; this
// block is a no-op for them. On a fresh-install device this block IS
// the schema. On an already-migrated DB it is a no-op.
//
// Re-running the migrator on any DB state above produces no errors and no
// changes -- this is the suite's "idempotent forward-only" convention.
public static class DatabaseMigrator
{
public static void Migrate(AppDataConnection db)
{
db.Execute(Sql);
db.Execute(RenameAndDropSql);
db.Execute(InitSql);
}
private const string Sql = """
CREATE TABLE IF NOT EXISTS aircrafts (
// Bring legacy `flights` / `aircrafts` schemas up to the renamed shape.
// Safe to re-run on any DB state.
private const string RenameAndDropSql = """
ALTER TABLE IF EXISTS aircrafts RENAME TO vehicles;
ALTER TABLE IF EXISTS flights RENAME TO missions;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'missions' AND column_name = 'aircraft_id') THEN
ALTER TABLE missions RENAME COLUMN aircraft_id TO vehicle_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'waypoints' AND column_name = 'flight_id') THEN
ALTER TABLE waypoints RENAME COLUMN flight_id TO mission_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'map_objects' AND column_name = 'flight_id') THEN
ALTER TABLE map_objects RENAME COLUMN flight_id TO mission_id;
END IF;
END $$;
ALTER INDEX IF EXISTS ix_flights_aircraft_id RENAME TO ix_missions_vehicle_id;
ALTER INDEX IF EXISTS ix_waypoints_flight_id RENAME TO ix_waypoints_mission_id;
ALTER INDEX IF EXISTS ix_map_objects_flight_id RENAME TO ix_map_objects_mission_id;
DROP TABLE IF EXISTS orthophotos;
DROP TABLE IF EXISTS gps_corrections;
""";
// Post-migration schema. CREATE TABLE IF NOT EXISTS is the idempotent path
// for fresh DBs; on already-migrated DBs every statement here is a no-op.
private const string InitSql = """
CREATE TABLE IF NOT EXISTS vehicles (
id UUID PRIMARY KEY,
type INTEGER NOT NULL DEFAULT 0,
model TEXT NOT NULL,
@@ -22,16 +70,16 @@ public static class DatabaseMigrator
is_default BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS flights (
CREATE TABLE IF NOT EXISTS missions (
id UUID PRIMARY KEY,
created_date TIMESTAMP NOT NULL DEFAULT NOW(),
name TEXT NOT NULL,
aircraft_id UUID NOT NULL REFERENCES aircrafts(id)
vehicle_id UUID NOT NULL REFERENCES vehicles(id)
);
CREATE TABLE IF NOT EXISTS waypoints (
id UUID PRIMARY KEY,
flight_id UUID NOT NULL REFERENCES flights(id),
mission_id UUID NOT NULL REFERENCES missions(id),
lat NUMERIC,
lon NUMERIC,
mgrs TEXT,
@@ -41,29 +89,9 @@ public static class DatabaseMigrator
height NUMERIC NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS orthophotos (
id TEXT PRIMARY KEY,
flight_id UUID NOT NULL REFERENCES flights(id),
name TEXT NOT NULL,
path TEXT NOT NULL,
lat NUMERIC,
lon NUMERIC,
mgrs TEXT,
uploaded_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS gps_corrections (
id UUID PRIMARY KEY,
flight_id UUID NOT NULL REFERENCES flights(id),
waypoint_id UUID NOT NULL REFERENCES waypoints(id),
original_gps TEXT NOT NULL,
corrected_gps TEXT NOT NULL,
applied_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS map_objects (
id UUID PRIMARY KEY,
flight_id UUID NOT NULL REFERENCES flights(id),
mission_id UUID NOT NULL REFERENCES missions(id),
h3_index TEXT NOT NULL,
mgrs TEXT NOT NULL,
lat NUMERIC,
@@ -78,11 +106,14 @@ public static class DatabaseMigrator
last_seen_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS ix_flights_aircraft_id ON flights(aircraft_id);
CREATE INDEX IF NOT EXISTS ix_waypoints_flight_id ON waypoints(flight_id);
CREATE INDEX IF NOT EXISTS ix_orthophotos_flight_id ON orthophotos(flight_id);
CREATE INDEX IF NOT EXISTS ix_gps_corrections_flight_id ON gps_corrections(flight_id);
CREATE INDEX IF NOT EXISTS ix_gps_corrections_waypoint_id ON gps_corrections(waypoint_id);
CREATE INDEX IF NOT EXISTS ix_map_objects_flight_id ON map_objects(flight_id);
CREATE INDEX IF NOT EXISTS ix_missions_vehicle_id ON missions(vehicle_id);
CREATE INDEX IF NOT EXISTS ix_waypoints_mission_id ON waypoints(mission_id);
CREATE INDEX IF NOT EXISTS ix_map_objects_mission_id ON map_objects(mission_id);
-- B12 (Option A): exactly-one-default vehicle is enforced by a partial
-- unique index. Only rows with is_default = true are indexed; two such
-- rows would conflict. Existing 0-default and 1-default DBs are valid.
CREATE UNIQUE INDEX IF NOT EXISTS ux_vehicles_one_default
ON vehicles (is_default) WHERE is_default = TRUE;
""";
}
+1 -1
View File
@@ -1,6 +1,6 @@
using LinqToDB.Mapping;
namespace Azaion.Flights.Database.Entities;
namespace Azaion.Missions.Database.Entities;
[Table("annotations")]
public class Annotation
+1 -1
View File
@@ -1,6 +1,6 @@
using LinqToDB.Mapping;
namespace Azaion.Flights.Database.Entities;
namespace Azaion.Missions.Database.Entities;
[Table("detection")]
public class Detection
+4 -4
View File
@@ -1,7 +1,7 @@
using LinqToDB.Mapping;
using Azaion.Flights.Enums;
using Azaion.Missions.Enums;
namespace Azaion.Flights.Database.Entities;
namespace Azaion.Missions.Database.Entities;
[Table("map_objects")]
public class MapObject
@@ -10,8 +10,8 @@ public class MapObject
[Column("id")]
public Guid Id { get; set; }
[Column("flight_id")]
public Guid FlightId { get; set; }
[Column("mission_id")]
public Guid MissionId { get; set; }
[Column("h3_index")]
public string H3Index { get; set; } = string.Empty;
+1 -1
View File
@@ -1,6 +1,6 @@
using LinqToDB.Mapping;
namespace Azaion.Flights.Database.Entities;
namespace Azaion.Missions.Database.Entities;
[Table("media")]
public class Media
+8 -9
View File
@@ -1,10 +1,9 @@
using LinqToDB.Mapping;
using Azaion.Flights.Enums;
namespace Azaion.Flights.Database.Entities;
namespace Azaion.Missions.Database.Entities;
[Table("flights")]
public class Flight
[Table("missions")]
public class Mission
{
[PrimaryKey]
[Column("id")]
@@ -16,12 +15,12 @@ public class Flight
[Column("name")]
public string Name { get; set; } = string.Empty;
[Column("aircraft_id")]
public Guid AircraftId { get; set; }
[Column("vehicle_id")]
public Guid VehicleId { get; set; }
[Association(ThisKey = nameof(AircraftId), OtherKey = nameof(Aircraft.Id))]
public Aircraft? Aircraft { get; set; }
[Association(ThisKey = nameof(VehicleId), OtherKey = nameof(Vehicle.Id))]
public Vehicle? Vehicle { get; set; }
[Association(ThisKey = nameof(Id), OtherKey = nameof(Waypoint.FlightId))]
[Association(ThisKey = nameof(Id), OtherKey = nameof(Waypoint.MissionId))]
public List<Waypoint> Waypoints { get; set; } = [];
}
+5 -5
View File
@@ -1,17 +1,17 @@
using LinqToDB.Mapping;
using Azaion.Flights.Enums;
using Azaion.Missions.Enums;
namespace Azaion.Flights.Database.Entities;
namespace Azaion.Missions.Database.Entities;
[Table("aircrafts")]
public class Aircraft
[Table("vehicles")]
public class Vehicle
{
[PrimaryKey]
[Column("id")]
public Guid Id { get; set; }
[Column("type")]
public AircraftType Type { get; set; }
public VehicleType Type { get; set; }
[Column("model")]
public string Model { get; set; } = string.Empty;
+6 -6
View File
@@ -1,7 +1,7 @@
using LinqToDB.Mapping;
using Azaion.Flights.Enums;
using Azaion.Missions.Enums;
namespace Azaion.Flights.Database.Entities;
namespace Azaion.Missions.Database.Entities;
[Table("waypoints")]
public class Waypoint
@@ -10,8 +10,8 @@ public class Waypoint
[Column("id")]
public Guid Id { get; set; }
[Column("flight_id")]
public Guid FlightId { get; set; }
[Column("mission_id")]
public Guid MissionId { get; set; }
[Column("lat")]
public decimal? Lat { get; set; }
@@ -34,6 +34,6 @@ public class Waypoint
[Column("height")]
public decimal Height { get; set; }
[Association(ThisKey = nameof(FlightId), OtherKey = nameof(Flight.Id))]
public Flight? Flight { get; set; }
[Association(ThisKey = nameof(MissionId), OtherKey = nameof(Mission.Id))]
public Mission? Mission { get; set; }
}
+1 -1
View File
@@ -13,4 +13,4 @@ COPY --from=build /app .
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/docker-entrypoint.sh", "dotnet", "Azaion.Flights.dll"]
ENTRYPOINT ["/docker-entrypoint.sh", "dotnet", "Azaion.Missions.dll"]
+5 -2
View File
@@ -1,8 +1,11 @@
namespace Azaion.Flights.Enums;
namespace Azaion.Missions.Enums;
// Numeric values are persisted in the `vehicles.fuel_type` column. Do NOT reorder
// or reassign existing values -- live rows would silently become a different fuel.
public enum FuelType
{
Electric = 0,
Gasoline = 1,
Diesel = 2
Diesel = 2,
SolidPropellant = 3
}
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Azaion.Flights.Enums;
namespace Azaion.Missions.Enums;
public enum ObjectStatus
{
+7 -3
View File
@@ -1,7 +1,11 @@
namespace Azaion.Flights.Enums;
namespace Azaion.Missions.Enums;
public enum AircraftType
// Numeric values are persisted in the `vehicles.type` column. Do NOT reorder or
// reassign existing values -- live rows would silently become a different type.
public enum VehicleType
{
Plane = 0,
Copter = 1
Copter = 1,
UGV = 2,
GuidedMissile = 3
}
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Azaion.Flights.Enums;
namespace Azaion.Missions.Enums;
public enum WaypointObjective
{
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Azaion.Flights.Enums;
namespace Azaion.Missions.Enums;
public enum WaypointSource
{
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Azaion.Flights.Infrastructure;
namespace Azaion.Missions.Infrastructure;
public static class ConfigurationResolver
{
+1 -1
View File
@@ -1,4 +1,4 @@
namespace Azaion.Flights.Infrastructure;
namespace Azaion.Missions.Infrastructure;
public static class CorsConfigurationValidator
{
+1 -1
View File
@@ -1,7 +1,7 @@
using System.Net;
using System.Text.Json;
namespace Azaion.Flights.Middleware;
namespace Azaion.Missions.Middleware;
public class ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger)
{
+7 -7
View File
@@ -1,10 +1,10 @@
using LinqToDB;
using LinqToDB.Data;
using Azaion.Flights.Auth;
using Azaion.Flights.Database;
using Azaion.Flights.Infrastructure;
using Azaion.Flights.Middleware;
using Azaion.Flights.Services;
using Azaion.Missions.Auth;
using Azaion.Missions.Database;
using Azaion.Missions.Infrastructure;
using Azaion.Missions.Middleware;
using Azaion.Missions.Services;
const string DatabaseUrlEnvVar = "DATABASE_URL";
const string DatabaseUrlConfigKey = "Database:Url";
@@ -27,9 +27,9 @@ builder.Services.AddScoped(_ =>
return new AppDataConnection(options);
});
builder.Services.AddScoped<FlightService>();
builder.Services.AddScoped<MissionService>();
builder.Services.AddScoped<WaypointService>();
builder.Services.AddScoped<AircraftService>();
builder.Services.AddScoped<VehicleService>();
builder.Services.AddJwtAuth(builder.Configuration);
+1 -1
View File
@@ -1,6 +1,6 @@
# Azaion.Missions
> **NOTE (forward-looking)**: this repo is being renamed `flights` -> `missions` (Jira AZ-EPIC, child B4). Until B4 + B5 land, the .NET project file is still `Azaion.Flights.csproj` and the namespace is `Azaion.Flights.*`. The forward-looking name is used here intentionally.
> **NOTE (forward-looking)**: this repo is being renamed `flights` -> `missions` (Jira AZ-EPIC, child B4). The Gitea repo rename + suite `.gitmodules` update + `git mv flights missions` (B4) is still pending.
.NET 10 REST API for **mission planning** (missions + waypoints) and the **vehicle catalog** (Plane / Copter / UGV / GuidedMissile) on Azaion edge devices.
+42 -44
View File
@@ -1,75 +1,75 @@
using LinqToDB;
using Azaion.Flights.Database;
using Azaion.Flights.Database.Entities;
using Azaion.Flights.DTOs;
using Azaion.Missions.Database;
using Azaion.Missions.Database.Entities;
using Azaion.Missions.DTOs;
namespace Azaion.Flights.Services;
namespace Azaion.Missions.Services;
public class FlightService(AppDataConnection db)
public class MissionService(AppDataConnection db)
{
public async Task<Flight> CreateFlight(CreateFlightRequest request)
public async Task<Mission> CreateMission(CreateMissionRequest request)
{
var aircraftExists = await db.Aircrafts.AnyAsync(a => a.Id == request.AircraftId);
if (!aircraftExists)
throw new ArgumentException($"Aircraft {request.AircraftId} not found");
var vehicleExists = await db.Vehicles.AnyAsync(v => v.Id == request.VehicleId);
if (!vehicleExists)
throw new ArgumentException($"Vehicle {request.VehicleId} not found");
var flight = new Flight
var mission = new Mission
{
Id = Guid.NewGuid(),
CreatedDate = request.CreatedDate ?? DateTime.UtcNow,
Name = request.Name,
AircraftId = request.AircraftId
VehicleId = request.VehicleId
};
await db.InsertAsync(flight);
return flight;
await db.InsertAsync(mission);
return mission;
}
public async Task<Flight> UpdateFlight(Guid id, UpdateFlightRequest request)
public async Task<Mission> UpdateMission(Guid id, UpdateMissionRequest request)
{
var flight = await db.Flights.FirstOrDefaultAsync(f => f.Id == id)
?? throw new KeyNotFoundException($"Flight {id} not found");
var mission = await db.Missions.FirstOrDefaultAsync(m => m.Id == id)
?? throw new KeyNotFoundException($"Mission {id} not found");
if (request.Name != null)
flight.Name = request.Name;
if (request.AircraftId.HasValue)
mission.Name = request.Name;
if (request.VehicleId.HasValue)
{
var aircraftExists = await db.Aircrafts.AnyAsync(a => a.Id == request.AircraftId.Value);
if (!aircraftExists)
throw new ArgumentException($"Aircraft {request.AircraftId} not found");
flight.AircraftId = request.AircraftId.Value;
var vehicleExists = await db.Vehicles.AnyAsync(v => v.Id == request.VehicleId.Value);
if (!vehicleExists)
throw new ArgumentException($"Vehicle {request.VehicleId} not found");
mission.VehicleId = request.VehicleId.Value;
}
await db.UpdateAsync(flight);
return flight;
await db.UpdateAsync(mission);
return mission;
}
public async Task<Flight> GetFlight(Guid id)
public async Task<Mission> GetMission(Guid id)
{
var flight = await db.Flights.FirstOrDefaultAsync(f => f.Id == id)
?? throw new KeyNotFoundException($"Flight {id} not found");
return flight;
var mission = await db.Missions.FirstOrDefaultAsync(m => m.Id == id)
?? throw new KeyNotFoundException($"Mission {id} not found");
return mission;
}
public async Task<PaginatedResponse<Flight>> GetFlights(GetFlightsQuery query)
public async Task<PaginatedResponse<Mission>> GetMissions(GetMissionsQuery query)
{
var q = db.Flights.AsQueryable();
var q = db.Missions.AsQueryable();
if (!string.IsNullOrEmpty(query.Name))
q = q.Where(f => f.Name.ToLower().Contains(query.Name.ToLower()));
q = q.Where(m => m.Name.ToLower().Contains(query.Name.ToLower()));
if (query.FromDate.HasValue)
q = q.Where(f => f.CreatedDate >= query.FromDate.Value);
q = q.Where(m => m.CreatedDate >= query.FromDate.Value);
if (query.ToDate.HasValue)
q = q.Where(f => f.CreatedDate <= query.ToDate.Value);
q = q.Where(m => m.CreatedDate <= query.ToDate.Value);
var totalCount = await q.CountAsync();
var items = await q
.OrderByDescending(f => f.CreatedDate)
.OrderByDescending(m => m.CreatedDate)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync();
return new PaginatedResponse<Flight>
return new PaginatedResponse<Mission>
{
Items = items,
TotalCount = totalCount,
@@ -78,16 +78,14 @@ public class FlightService(AppDataConnection db)
};
}
public async Task DeleteFlight(Guid id)
public async Task DeleteMission(Guid id)
{
var flight = await db.Flights.FirstOrDefaultAsync(f => f.Id == id)
?? throw new KeyNotFoundException($"Flight {id} not found");
var mission = await db.Missions.FirstOrDefaultAsync(m => m.Id == id)
?? throw new KeyNotFoundException($"Mission {id} not found");
await db.MapObjects.DeleteAsync(m => m.FlightId == id);
await db.GpsCorrections.DeleteAsync(g => g.FlightId == id);
await db.Orthophotos.DeleteAsync(o => o.FlightId == id);
await db.MapObjects.DeleteAsync(o => o.MissionId == id);
var waypointIds = await db.Waypoints.Where(w => w.FlightId == id).Select(w => w.Id).ToListAsync();
var waypointIds = await db.Waypoints.Where(w => w.MissionId == id).Select(w => w.Id).ToListAsync();
if (waypointIds.Count > 0)
{
var mediaIds = await db.Media.Where(m => m.WaypointId != null && waypointIds.Contains(m.WaypointId!.Value))
@@ -103,7 +101,7 @@ public class FlightService(AppDataConnection db)
await db.Media.DeleteAsync(m => m.WaypointId != null && waypointIds.Contains(m.WaypointId!.Value));
}
await db.Waypoints.DeleteAsync(w => w.FlightId == id);
await db.Flights.DeleteAsync(f => f.Id == id);
await db.Waypoints.DeleteAsync(w => w.MissionId == id);
await db.Missions.DeleteAsync(m => m.Id == id);
}
}
+82 -50
View File
@@ -1,17 +1,21 @@
using Azaion.Flights.Database;
using Azaion.Flights.Database.Entities;
using Azaion.Flights.DTOs;
using System.Data;
using Azaion.Missions.Database;
using Azaion.Missions.Database.Entities;
using Azaion.Missions.DTOs;
namespace Azaion.Flights.Services;
namespace Azaion.Missions.Services;
public class AircraftService(AppDataConnection db)
public class VehicleService(AppDataConnection db)
{
public async Task<Aircraft> CreateAircraft(CreateAircraftRequest request)
// B12 (Option A): "exactly one default vehicle" is the user-visible truth.
// Every code path that sets is_default=true clears existing defaults and
// assigns the new default inside a Serializable transaction so two
// concurrent default-set ops cannot leave 0 or 2 defaults. The DB-level
// partial unique index `ux_vehicles_one_default` (DatabaseMigrator) is the
// belt-and-braces backstop if a future code path forgets the transaction.
public async Task<Vehicle> CreateVehicle(CreateVehicleRequest request)
{
if (request.IsDefault)
await db.Aircrafts.Where(a => a.IsDefault).Set(a => a.IsDefault, false).UpdateAsync();
var aircraft = new Aircraft
var vehicle = new Vehicle
{
Id = Guid.NewGuid(),
Type = request.Type,
@@ -23,80 +27,108 @@ public class AircraftService(AppDataConnection db)
EngineConsumptionIdle = request.EngineConsumptionIdle,
IsDefault = request.IsDefault
};
await db.InsertAsync(aircraft);
return aircraft;
if (request.IsDefault)
{
await using var tx = await db.BeginTransactionAsync(IsolationLevel.Serializable);
await db.Vehicles.Where(v => v.IsDefault).Set(v => v.IsDefault, false).UpdateAsync();
await db.InsertAsync(vehicle);
await tx.CommitAsync();
}
else
{
await db.InsertAsync(vehicle);
}
public async Task<Aircraft> UpdateAircraft(Guid id, UpdateAircraftRequest request)
return vehicle;
}
public async Task<Vehicle> UpdateVehicle(Guid id, UpdateVehicleRequest request)
{
var aircraft = await db.Aircrafts.FirstOrDefaultAsync(a => a.Id == id)
?? throw new KeyNotFoundException($"Aircraft {id} not found");
var vehicle = await db.Vehicles.FirstOrDefaultAsync(v => v.Id == id)
?? throw new KeyNotFoundException($"Vehicle {id} not found");
if (request.Type.HasValue)
aircraft.Type = request.Type.Value;
vehicle.Type = request.Type.Value;
if (request.Model != null)
aircraft.Model = request.Model;
vehicle.Model = request.Model;
if (request.Name != null)
aircraft.Name = request.Name;
vehicle.Name = request.Name;
if (request.FuelType.HasValue)
aircraft.FuelType = request.FuelType.Value;
vehicle.FuelType = request.FuelType.Value;
if (request.BatteryCapacity.HasValue)
aircraft.BatteryCapacity = request.BatteryCapacity.Value;
vehicle.BatteryCapacity = request.BatteryCapacity.Value;
if (request.EngineConsumption.HasValue)
aircraft.EngineConsumption = request.EngineConsumption.Value;
vehicle.EngineConsumption = request.EngineConsumption.Value;
if (request.EngineConsumptionIdle.HasValue)
aircraft.EngineConsumptionIdle = request.EngineConsumptionIdle.Value;
if (request.IsDefault.HasValue)
vehicle.EngineConsumptionIdle = request.EngineConsumptionIdle.Value;
if (request.IsDefault is true)
{
if (request.IsDefault.Value)
await db.Aircrafts.Where(a => a.IsDefault).Set(a => a.IsDefault, false).UpdateAsync();
aircraft.IsDefault = request.IsDefault.Value;
await using var tx = await db.BeginTransactionAsync(IsolationLevel.Serializable);
await db.Vehicles.Where(v => v.IsDefault && v.Id != id).Set(v => v.IsDefault, false).UpdateAsync();
vehicle.IsDefault = true;
await db.UpdateAsync(vehicle);
await tx.CommitAsync();
}
else
{
if (request.IsDefault is false)
vehicle.IsDefault = false;
await db.UpdateAsync(vehicle);
}
await db.UpdateAsync(aircraft);
return aircraft;
return vehicle;
}
public async Task<Aircraft> GetAircraft(Guid id)
public async Task<Vehicle> GetVehicle(Guid id)
{
var aircraft = await db.Aircrafts.FirstOrDefaultAsync(a => a.Id == id)
?? throw new KeyNotFoundException($"Aircraft {id} not found");
return aircraft;
var vehicle = await db.Vehicles.FirstOrDefaultAsync(v => v.Id == id)
?? throw new KeyNotFoundException($"Vehicle {id} not found");
return vehicle;
}
public async Task<List<Aircraft>> GetAircrafts(GetAircraftsQuery query)
public async Task<List<Vehicle>> GetVehicles(GetVehiclesQuery query)
{
var q = db.Aircrafts.AsQueryable();
var q = db.Vehicles.AsQueryable();
if (!string.IsNullOrEmpty(query.Name))
q = q.Where(a => a.Name.ToLower().Contains(query.Name.ToLower()));
q = q.Where(v => v.Name.ToLower().Contains(query.Name.ToLower()));
if (query.IsDefault.HasValue)
q = q.Where(a => a.IsDefault == query.IsDefault.Value);
q = q.Where(v => v.IsDefault == query.IsDefault.Value);
return await q.OrderBy(a => a.Name).ToListAsync();
return await q.OrderBy(v => v.Name).ToListAsync();
}
public async Task DeleteAircraft(Guid id)
public async Task DeleteVehicle(Guid id)
{
var hasFlights = await db.Flights.AnyAsync(f => f.AircraftId == id);
if (hasFlights)
throw new InvalidOperationException($"Aircraft {id} is referenced by flights");
var hasMissions = await db.Missions.AnyAsync(m => m.VehicleId == id);
if (hasMissions)
throw new InvalidOperationException($"Vehicle {id} is referenced by missions");
var aircraft = await db.Aircrafts.FirstOrDefaultAsync(a => a.Id == id)
?? throw new KeyNotFoundException($"Aircraft {id} not found");
var vehicle = await db.Vehicles.FirstOrDefaultAsync(v => v.Id == id)
?? throw new KeyNotFoundException($"Vehicle {id} not found");
await db.Aircrafts.DeleteAsync(a => a.Id == id);
await db.Vehicles.DeleteAsync(v => v.Id == id);
}
public async Task SetDefault(Guid id, SetDefaultRequest request)
{
var aircraft = await db.Aircrafts.FirstOrDefaultAsync(a => a.Id == id)
?? throw new KeyNotFoundException($"Aircraft {id} not found");
var vehicle = await db.Vehicles.FirstOrDefaultAsync(v => v.Id == id)
?? throw new KeyNotFoundException($"Vehicle {id} not found");
if (request.IsDefault)
await db.Aircrafts.Where(a => a.IsDefault).Set(a => a.IsDefault, false).UpdateAsync();
aircraft.IsDefault = request.IsDefault;
await db.UpdateAsync(aircraft);
{
await using var tx = await db.BeginTransactionAsync(IsolationLevel.Serializable);
await db.Vehicles.Where(v => v.IsDefault && v.Id != id).Set(v => v.IsDefault, false).UpdateAsync();
vehicle.IsDefault = true;
await db.UpdateAsync(vehicle);
await tx.CommitAsync();
}
else
{
vehicle.IsDefault = false;
await db.UpdateAsync(vehicle);
}
}
}
+15 -17
View File
@@ -1,22 +1,21 @@
using Azaion.Flights.Database;
using Azaion.Flights.Database.Entities;
using Azaion.Flights.DTOs;
using Azaion.Flights.Enums;
using Azaion.Missions.Database;
using Azaion.Missions.Database.Entities;
using Azaion.Missions.DTOs;
namespace Azaion.Flights.Services;
namespace Azaion.Missions.Services;
public class WaypointService(AppDataConnection db)
{
public async Task<Waypoint> CreateWaypoint(Guid flightId, CreateWaypointRequest request)
public async Task<Waypoint> CreateWaypoint(Guid missionId, CreateWaypointRequest request)
{
var flightExists = await db.Flights.AnyAsync(f => f.Id == flightId);
if (!flightExists)
throw new KeyNotFoundException($"Flight {flightId} not found");
var missionExists = await db.Missions.AnyAsync(m => m.Id == missionId);
if (!missionExists)
throw new KeyNotFoundException($"Mission {missionId} not found");
var waypoint = new Waypoint
{
Id = Guid.NewGuid(),
FlightId = flightId,
MissionId = missionId,
Lat = request.GeoPoint?.Lat,
Lon = request.GeoPoint?.Lon,
Mgrs = request.GeoPoint?.Mgrs,
@@ -29,9 +28,9 @@ public class WaypointService(AppDataConnection db)
return waypoint;
}
public async Task<Waypoint> UpdateWaypoint(Guid flightId, Guid waypointId, UpdateWaypointRequest request)
public async Task<Waypoint> UpdateWaypoint(Guid missionId, Guid waypointId, UpdateWaypointRequest request)
{
var waypoint = await db.Waypoints.FirstOrDefaultAsync(w => w.FlightId == flightId && w.Id == waypointId)
var waypoint = await db.Waypoints.FirstOrDefaultAsync(w => w.MissionId == missionId && w.Id == waypointId)
?? throw new KeyNotFoundException($"Waypoint {waypointId} not found");
waypoint.Lat = request.GeoPoint?.Lat;
@@ -46,17 +45,17 @@ public class WaypointService(AppDataConnection db)
return waypoint;
}
public async Task<List<Waypoint>> GetWaypoints(Guid flightId)
public async Task<List<Waypoint>> GetWaypoints(Guid missionId)
{
return await db.Waypoints
.Where(w => w.FlightId == flightId)
.Where(w => w.MissionId == missionId)
.OrderBy(w => w.OrderNum)
.ToListAsync();
}
public async Task DeleteWaypoint(Guid flightId, Guid waypointId)
public async Task DeleteWaypoint(Guid missionId, Guid waypointId)
{
var waypoint = await db.Waypoints.FirstOrDefaultAsync(w => w.FlightId == flightId && w.Id == waypointId)
var waypoint = await db.Waypoints.FirstOrDefaultAsync(w => w.MissionId == missionId && w.Id == waypointId)
?? throw new KeyNotFoundException($"Waypoint {waypointId} not found");
var mediaIds = await db.Media.Where(m => m.WaypointId == waypointId).Select(m => m.Id).ToListAsync();
@@ -69,7 +68,6 @@ public class WaypointService(AppDataConnection db)
await db.Annotations.DeleteAsync(a => mediaIds.Contains(a.MediaId));
}
await db.Media.DeleteAsync(m => m.WaypointId == waypointId);
await db.GpsCorrections.DeleteAsync(g => g.WaypointId == waypointId);
await db.Waypoints.DeleteAsync(w => w.Id == waypointId);
}
}
@@ -6,7 +6,7 @@
**Implementation status**: ✅ implemented (with one stricter-than-spec rule -- see Caveats #1).
> **NOTE (forward-looking)**: file paths and identifiers below reflect the post-rename state. Today's source still uses `Aircraft*` filenames + `[Route("aircrafts")]`. The renames are tracked under Jira AZ-EPIC children B6 (domain rename) and B8 (HTTP routes). The doc IS the spec for that work.
> **NOTE (forward-looking)**: file paths and identifiers below reflect the post-rename state. B5 (namespace) and B6 (domain) have landed; route attributes still match `[Route("aircrafts")]` until B8 ships.
**Files** (post-rename):
- HTTP: `Controllers/VehiclesController.cs`
@@ -94,7 +94,7 @@ None.
3. **No validation on request DTOs** (no `[Required]`, no range checks): empty `Name`, negative `BatteryCapacity`, invalid enum int values, etc., are accepted.
4. **Entity returned on the wire** with no DTO mapping -- couples DB column shape to HTTP response shape. Today benign because `Vehicle` has no associations.
5. **Case-insensitive search via `LOWER(...)`** -- full-table scan; fine while the catalog is small.
6. **`FuelType` may not fit `GuidedMissile`** -- the existing `{ Electric, Gasoline, Diesel }` set assumes a powered, reusable vehicle. Carry forward as Phase C decision (see plan); may spawn a follow-up ticket to allow a `None` value or make `FuelType` nullable for missiles.
6. **`FuelType` for `GuidedMissile`** -- decided in B6 (2026-05-15): extend the enum with `SolidPropellant = 3` and keep `FuelType` non-nullable on `Vehicle`. `GuidedMissile` rows persist with `FuelType = SolidPropellant`. Numeric values 0/1/2 are unchanged so existing rows are untouched. Reasoning: nullable would silently propagate to the UI (it assumes a fuel type); a value-typed default keeps every consumer working without a code change.
## 8. Dependency Graph
@@ -4,7 +4,7 @@
**Implementation status**: ✅ implemented. Single policy `FL` is declared and consumed by every controller in the post-rename target scope.
> **NOTE (forward-looking)**: post-rename + post-GPS-Denied-removal. Today's `JwtExtensions.cs` also declares a `"GPS"` policy reserved for the (now-removed-from-this-repo) GPS-Denied endpoints. After Jira AZ-EPIC child B7 lands, only `"FL"` remains.
> **NOTE**: B7 has landed (2026-05-15). The `"GPS"` policy and the GPS-Denied entities (`Orthophoto`, `GpsCorrection`) have been removed from this service. Only `"FL"` remains.
**Files**: `Auth/JwtExtensions.cs`, `Infrastructure/ConfigurationResolver.cs` (consumed for fail-fast value resolution)
@@ -34,12 +34,11 @@ public static IServiceCollection AddJwtAuth(this IServiceCollection services, IC
Each value is resolved env-var-first, then config-key, then throws `InvalidOperationException` at startup. There is **no dev fallback**. The legacy `JWT_SECRET` env var is no longer consulted.
Side effects: registers `JwtBearerDefaults.AuthenticationScheme` and **two** named authorization policies in DI (one is removed after B7 lands):
Side effects: registers `JwtBearerDefaults.AuthenticationScheme` and **one** named authorization policy in DI:
| Policy | Requirement | Notes |
|--------|-------------|-------|
| `"FL"` | JWT contains a `permissions` claim with value `"FL"` | Permanent |
| `"GPS"` | JWT contains a `permissions` claim with value `"GPS"` | Removed in Jira B7 (legacy GPS-Denied routes are moving out of this repo) |
| `"FL"` | JWT contains a `permissions` claim with value `"FL"` | Only policy declared by this service. The legacy `"GPS"` policy was removed when B7 landed (2026-05-15). |
## 3. JWT model (this service) vs. suite-wide pattern
+4 -4
View File
@@ -7,11 +7,11 @@ name: Decompose Tests
status: not_started
sub_step:
phase: 0
name: blocked-on-tracker-auth
detail: "atlassian MCP not connected; tracker decision A/B/C/D pending"
name: awaiting-invocation
detail: ""
retry_count: 0
cycle: 1
tracker: jira
## Rename tracking (Jira AZ-EPIC + child stories B1B12)
See `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md` for the leftover entry; it is intentionally retained until B4B12 ship.
## Rename tracking (Jira AZ-EPIC + child stories B1-B12)
See `_docs/_process_leftovers/2026-05-14_rename-flights-to-missions.md`. Local code work for B5, B6, B7, B8, B9, B12 landed 2026-05-15; .woodpecker tag rename done. Cross-repo work pending: B4 (suite), B10-suite, B11 (autopilot + ui), B12 spec catch-up in suite. Leftover stays until those land.
@@ -33,7 +33,16 @@ The rest of the work — code rename, DB migration, HTTP route rename, repo rena
## Jira ticket creation status
**status: tickets-created + local-task-files-written** (2026-05-14)
**status: in-flight** (last updated 2026-05-15)
- Tickets created 2026-05-14.
- Local code work in `flights/` workspace landed 2026-05-15: B5, B6, B7, B8, B9, B12 transitioned to Done; their task files moved to `_docs/tasks/done/` with `Status: Done (2026-05-15)` headers. Local `.woodpecker/build-arm.yml` image-tag rename done as part of the same session (the local portion of B10).
- Cross-repo / suite work still pending: B4 (Gitea repo rename + suite `.gitmodules` + `git mv flights missions`), B10 suite-side (`_infra/` compose service block), B11 (autopilot + ui consumer cutover, suite e2e harness).
- Cross-repo follow-up surfaced by B12: `azaion-suite/_docs/02_missions.md` Vehicles section needs the rule wording: "exactly one vehicle has is_default = true at any time; toggle on a non-default unsets the previous default in the same transaction." Add as part of the suite-side work.
(Original creation snapshot retained below for traceability.)
**Original status (2026-05-14)**: tickets-created + local-task-files-written
Local task files were created under the project convention `<repo>/_docs/tasks/{todo,done}/AZ-<n>_<slug>.md` so the existing habit of mirroring tracker tickets as reviewable markdown is preserved. Per-repo work lives in the repo it touches; suite-level / multi-repo work stays in `azaion-suite/_docs/tasks/`.
@@ -58,21 +67,21 @@ In the suite repo (`azaion-suite/_docs/tasks/`):
| Plan ID | Jira | Type | SP | Status |
|---------|------|------|----|--------|
| Epic | [AZ-539](https://denyspopov.atlassian.net/browse/AZ-539) | Epic | -- | To Do |
| B1 | [AZ-540](https://denyspopov.atlassian.net/browse/AZ-540) | Task | 3 | **Done** (this turn) |
| B2 | [AZ-541](https://denyspopov.atlassian.net/browse/AZ-541) | Task | 3 | **Done** (this turn) |
| B3 | [AZ-542](https://denyspopov.atlassian.net/browse/AZ-542) | Task | 3 | **Done** (this turn) |
| B4 | [AZ-543](https://denyspopov.atlassian.net/browse/AZ-543) | Task | 3 | To Do |
| B5 | [AZ-544](https://denyspopov.atlassian.net/browse/AZ-544) | Story | 3 | To Do |
| B6 | [AZ-545](https://denyspopov.atlassian.net/browse/AZ-545) | Story | 5 | To Do |
| B7 | [AZ-546](https://denyspopov.atlassian.net/browse/AZ-546) | Story | 3 | To Do |
| B8 | [AZ-547](https://denyspopov.atlassian.net/browse/AZ-547) | Story | 3 | To Do |
| B9 | [AZ-548](https://denyspopov.atlassian.net/browse/AZ-548) | Story | 5 | To Do |
| B10 | [AZ-549](https://denyspopov.atlassian.net/browse/AZ-549) | Task | 2 | To Do |
| B11 | [AZ-550](https://denyspopov.atlassian.net/browse/AZ-550) | Story | 5 | To Do |
| B12 | [AZ-551](https://denyspopov.atlassian.net/browse/AZ-551) | Task | 2 | To Do |
| Epic | [AZ-539](https://denyspopov.atlassian.net/browse/AZ-539) | Epic | -- | To Do (close after B4/B10-suite/B11) |
| B1 | [AZ-540](https://denyspopov.atlassian.net/browse/AZ-540) | Task | 3 | **Done** (2026-05-14) |
| B2 | [AZ-541](https://denyspopov.atlassian.net/browse/AZ-541) | Task | 3 | **Done** (2026-05-14) |
| B3 | [AZ-542](https://denyspopov.atlassian.net/browse/AZ-542) | Task | 3 | **Done** (2026-05-14) |
| B4 | [AZ-543](https://denyspopov.atlassian.net/browse/AZ-543) | Task | 3 | To Do (suite repo) |
| B5 | [AZ-544](https://denyspopov.atlassian.net/browse/AZ-544) | Story | 3 | **Done** (2026-05-15) |
| B6 | [AZ-545](https://denyspopov.atlassian.net/browse/AZ-545) | Story | 5 | **Done** (2026-05-15) |
| B7 | [AZ-546](https://denyspopov.atlassian.net/browse/AZ-546) | Story | 3 | **Done** (2026-05-15) |
| B8 | [AZ-547](https://denyspopov.atlassian.net/browse/AZ-547) | Story | 3 | **Done** (2026-05-15) |
| B9 | [AZ-548](https://denyspopov.atlassian.net/browse/AZ-548) | Story | 5 | **Done** (2026-05-15) |
| B10 | [AZ-549](https://denyspopov.atlassian.net/browse/AZ-549) | Task | 2 | Partial (local `.woodpecker/build-arm.yml` updated 2026-05-15; suite compose still To Do) |
| B11 | [AZ-550](https://denyspopov.atlassian.net/browse/AZ-550) | Story | 5 | To Do (suite + autopilot + ui repos) |
| B12 | [AZ-551](https://denyspopov.atlassian.net/browse/AZ-551) | Task | 2 | **Done** (2026-05-15) -- code + DB index landed; suite spec wording catch-up still pending |
Total: 1 Epic + 12 child tickets, 35 SP across remaining 9 tickets.
Total: 1 Epic + 12 child tickets. 9 of 12 children Done. Remaining work: B4, B11, suite-side B10, plus B12's spec catch-up in `azaion-suite/_docs/02_missions.md`.
## Replay obligation
@@ -1,50 +0,0 @@
# Leftover: Step 5 (Decompose Tests) blocked on tracker auth
**Recorded**: 2026-05-14T20:51:00Z (Thursday)
**Blocker**: `user-atlassian-mcp` returns "Not connected" (verified via `getAccessibleAtlassianResources`).
**Type**: tracker availability — NOT a deferrable "non-user blocker"; the autodev tracker rule (`.cursor/rules/tracker.mdc` Tracker Availability Gate) requires explicit user decision (Retry / `tracker: local`).
## What is pending
Step 5 (Decompose Tests, tests-only mode) needs to run:
1. **Step 1t** — Test Infrastructure Bootstrap → creates `todo/[TRACKER-ID]_test_infrastructure.md` + matching Jira ticket
2. **Step 3** — Blackbox Test Task Decomposition → produces one task file per blackbox/perf/res/sec/res-lim scenario referenced in `_docs/02_document/tests/*.md`. Estimated 1220 task files based on the current spec spread (FT-P-01…FT-P-18, FT-N-01…FT-N-08, NFT-PERF-01…NFT-PERF-04, NFT-RES-01…NFT-RES-08, NFT-SEC-01…NFT-SEC-13, NFT-RES-LIM-01…NFT-RES-LIM-04).
3. **Step 4** — Cross-Verification → produces `_docs/02_tasks/_dependencies_table.md` and verifies AC/restriction coverage.
Each task file must have a Jira ticket created inline (per `.cursor/skills/decompose/SKILL.md` Save Timing table) and then be renamed from numeric prefix to `AZ-<id>` prefix.
## Inputs ready
- `_docs/02_document/tests/environment.md`
- `_docs/02_document/tests/test-data.md`
- `_docs/02_document/tests/blackbox-tests.md`
- `_docs/02_document/tests/performance-tests.md`
- `_docs/02_document/tests/resilience-tests.md`
- `_docs/02_document/tests/security-tests.md`
- `_docs/02_document/tests/resource-limit-tests.md` (need to verify exists)
- `_docs/02_document/tests/traceability-matrix.md` ✓ (post-2026-05-14 drift Phase 2 re-issue, 97% in-scope coverage)
- `_docs/00_problem/{problem,restrictions,acceptance_criteria}.md` ✓ (post-drift-revision)
## Resolution paths
The next `/autodev` invocation MUST resolve one of:
- **(preferred) Retry auth**: User authenticates `user-atlassian-mcp` via Cursor's MCP UI; autodev then proceeds normally and creates AZ-prefixed task files with live Jira tickets.
- **`tracker: local` mode** (only with explicit user acceptance): tasks are written with numeric prefix + `Tracker: pending` header marker; state file's `tracker:` field is changed to `local`; a future invocation with a working Jira MCP runs a "Tracker Pending Sync" to back-fill tickets and rename the files.
## Step 4 deliverables (already applied — DO NOT redo)
- `Auth/JwtExtensions.cs` — JWKS refresh-interval optional config (C01)
- `Infrastructure/ConfigurationResolver.cs``ResolveOptionalPositiveIntOrThrow` helper (C01)
- `Dockerfile` + new `docker-entrypoint.sh` — runs `update-ca-certificates` at container start (C02)
- `docker-compose.test.yml` — passes 30s / 10s JWKS refresh intervals to `missions` (C01)
- `_docs/04_refactoring/01-testability-refactoring/{list-of-changes,deferred_to_refactor,testability_changes_summary}.md`
`dotnet build -c Release` clean (0 warnings, 0 errors). `ReadLints` clean on edited files.
## Replay procedure when Atlassian MCP is back
1. On next `/autodev`, the Bootstrap step (B1) reads this leftover, verifies MCP connectivity via `getAccessibleAtlassianResources`, and either:
- **MCP works** → delete this leftover, autodev proceeds to Step 5 normally.
- **MCP still down** → autodev surfaces the Choose A/B/C/D again (see `protocols.md`).
2. If the user chose `tracker: local` in the interim and tasks were created with numeric prefixes, the next "Tracker Pending Sync" walks `_docs/02_tasks/todo/*.md` looking for `Tracker: pending` headers, creates the matching Jira ticket per task, rewrites the header, and renames the file from `NN_xxx.md` to `AZ-<id>_xxx.md`.
@@ -8,6 +8,7 @@
**Component**: `missions/Azaion.Flights.csproj`, `missions/Program.cs`, every C# file with `namespace Azaion.Flights...` or `using Azaion.Flights...`, `missions/.cursor/skills` if any reference the namespace, the suite-level docker-compose `image:` field stays as-is until B10
**Tracker**: AZ-544
**Epic**: AZ-539
**Status**: Done (2026-05-15)
## Outcome
@@ -8,6 +8,7 @@
**Component**: `missions/Domain/`, `missions/Services/`, `missions/Controllers/`, `missions/Auth/` (claim names stay; only domain types rename), `missions/DataLayer/` (linq2db `[Table]` attribute strings AND foreign-key column names — but actual SQL migration is B9)
**Tracker**: AZ-545
**Epic**: AZ-539
**Status**: Done (2026-05-15)
## Problem
@@ -8,6 +8,7 @@
**Component**: `missions/Domain/` (Orthophoto, GpsCorrection), `missions/Controllers/` (orthophoto + live-gps + gps-correction endpoints), `missions/Services/` (corresponding service methods), `missions/Auth/` (the `"GPS"` policy), `missions/Services/MissionService` (cascade-delete branches)
**Tracker**: AZ-546
**Epic**: AZ-539
**Status**: Done (2026-05-15)
## Problem
@@ -8,6 +8,7 @@
**Component**: `missions/Controllers/` (route attributes), OpenAPI spec generation pipeline (whatever the project uses today), Postman / curl examples in `_docs/02_document/components/06_http_conventions/description.md` and `azaion-suite/_docs/02_missions.md`
**Tracker**: AZ-547
**Epic**: AZ-539
**Status**: Done (2026-05-15)
## Outcome
@@ -8,6 +8,7 @@
**Component**: `missions/DataLayer/Migrations/` (or whatever the project's actual migration directory is at HEAD), the `init` SQL seed for fresh-install devices
**Tracker**: AZ-548
**Epic**: AZ-539
**Status**: Done (2026-05-15)
## Outcome
@@ -8,6 +8,7 @@
**Component**: `missions/Services/VehicleService.cs`, `missions/DataLayer/` (if a partial unique index is added), `azaion-suite/_docs/02_missions.md` (spec catch-up if the rule stays)
**Tracker**: AZ-551
**Epic**: AZ-539
**Status**: Done (2026-05-15)
## Problem
+5 -2
View File
@@ -1,6 +1,9 @@
## Test compose stack for the missions service.
## Naming: post-rename target. Pre-rename code path runs the same compose against the
## existing Azaion.Flights.csproj entrypoint -- tests will be RED until B5-B8 land.
## Naming: post-rename target. Project entrypoint is Azaion.Missions.csproj.
## B5 (namespace), B6 (domain), B7 (drop GPS-Denied), B8 (HTTP routes), B9
## (DB migration), B12 (default-vehicle rule) have all landed locally.
## Cross-repo work pending: B4 (Gitea repo rename + suite .gitmodules + git mv),
## B10 (suite compose service block), B11 (autopilot/ui consumer cutover).
## Documented in _docs/02_document/tests/environment.md.
##
## Post-2026-05-14 drift re-verification: JWT model is ECDSA-SHA256 with JWKS