mirror of
https://github.com/azaion/missions.git
synced 2026-06-21 10:51:08 +00:00
refactor: enhance JWT authentication and CORS configuration
Updated JWT authentication to use configuration values instead of hardcoded secrets, improving security and flexibility. Enhanced CORS policy to conditionally allow origins based on configuration settings, with logging for permissive defaults. Updated README to reflect project renaming and clarify service context.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Module: `Azaion.Missions.Auth`
|
||||
|
||||
**Files (1)**: `Auth/JwtExtensions.cs`
|
||||
|
||||
> **NOTE (forward-looking)**: this module's source paths and namespace will become `Azaion.Missions.*` after the `flights -> missions` rename ticket lands (Jira AZ-EPIC, child B5 / B7). Today the file still says `Azaion.Flights`. The behavior described below already matches the post-rename intent: only the `FL` policy remains, the `GPS` policy is removed (per B7).
|
||||
|
||||
## Purpose
|
||||
|
||||
Single static extension (`AddJwtAuth`) that registers JWT bearer authentication and the named authorization policy `FL` used by controllers.
|
||||
|
||||
## Public Interface
|
||||
|
||||
```csharp
|
||||
public static class JwtExtensions {
|
||||
public static IServiceCollection AddJwtAuth(this IServiceCollection services, string jwtSecret);
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Logic
|
||||
|
||||
1. `AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(...)` configures token validation:
|
||||
- `IssuerSigningKey = SymmetricSecurityKey(UTF-8(jwtSecret))` -> **HS256 / shared-secret** validation.
|
||||
- `ValidateIssuer = false`, `ValidateAudience = false` -- `iss` / `aud` claims are NOT checked. Tokens with any issuer/audience are accepted as long as the signature and lifetime are valid. (CMMC L2 finding -- see `../../suite/_docs/05_security/cmmc_l2_scorecard.md` row 3 and the suite-level remediation tracked under AZ-487/AZ-494.)
|
||||
- `ValidateIssuerSigningKey = true`, `ValidateLifetime = true`.
|
||||
- `ClockSkew = 1 minute` (tighter than the .NET default of 5 minutes).
|
||||
2. `AddAuthorizationBuilder()` registers one policy:
|
||||
- `"FL"` -> requires the JWT to contain a `permissions` claim with value `"FL"`.
|
||||
|
||||
`RequireClaim("permissions", "FL")` matches on a claim named `"permissions"` whose value equals `"FL"`. With multi-permission tokens, the token typically has multiple `permissions` claims, one per permission.
|
||||
|
||||
## Suite-wide JWT pattern
|
||||
|
||||
This service consumes JWTs minted by the remote `admin` service against the central user PostgreSQL (per `../../suite/_docs/00_top_level_architecture.md` and `../../suite/_docs/10_auth.md`). Every `.NET` service in the suite -- `admin`, `annotations`, `missions` (this one), `satellite-provider` -- shares one HMAC secret (`JWT_SECRET`) and validates tokens locally with no network round-trip. The user logs in once at the UI; the resulting bearer token is reusable across every service.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `Microsoft.AspNetCore.Authentication.JwtBearer` (NuGet, pinned to `10.0.5`)
|
||||
- `Microsoft.IdentityModel.Tokens` (transitive -- `SymmetricSecurityKey`, `TokenValidationParameters`)
|
||||
- `System.Text` (for `Encoding.UTF8`)
|
||||
|
||||
No internal dependencies.
|
||||
|
||||
## Consumers
|
||||
|
||||
- `Program.cs` -- `builder.Services.AddJwtAuth(jwtSecret)` is called once at startup.
|
||||
- Controllers reference the policy indirectly via `[Authorize(Policy = "FL")]` (used on both `VehiclesController` and `MissionsController`).
|
||||
|
||||
## Configuration
|
||||
|
||||
Reads no configuration directly -- `jwtSecret` is passed by the caller. `Program.cs` resolves it from `IConfiguration["JWT_SECRET"]` -> `Environment.GetEnvironmentVariable("JWT_SECRET")` -> fallback `"development-secret-key-min-32-chars!!"`.
|
||||
|
||||
## External Integrations
|
||||
|
||||
None at the network level -- token validation is purely cryptographic against the shared secret.
|
||||
|
||||
## Security
|
||||
|
||||
- **Algorithm**: HMAC-SHA256 via `SymmetricSecurityKey`. The token issuer (`admin`) must use the SAME secret to sign -- there is no public-key flow.
|
||||
- **No issuer/audience validation** -- any service that knows the shared secret can mint tokens that this API will accept. This trust model assumes the secret is private to the suite; it is not safe for multi-tenant or third-party token issuance.
|
||||
- **Clock skew tolerance**: 1 minute (tight, intentional).
|
||||
- The fallback secret in `Program.cs` is hardcoded. It MUST be overridden in production.
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
1. **Single permission (`FL`) gates the whole API.** All routes carry `[Authorize(Policy = "FL")]`. There is no operator-vs-admin distinction at this layer; granular permissions are governed by the role->permission matrix in `../../suite/_docs/00_roles_permissions.md`.
|
||||
2. **No authentication scheme name override** -- uses `JwtBearerDefaults.AuthenticationScheme` ("Bearer"). Consistent.
|
||||
3. **No claim type for "user id"** -- only the `permissions` claim is consumed; whatever subject identity the issuer puts in the token is ignored at the policy layer. Audit logs / business rules that need a user identifier currently have no per-call user binding (services don't take `HttpContext.User`). When `02_mission_planning` adds attribution to actions like waypoint-set / mission-rename, this becomes a blocker.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Module: `Azaion.Missions.Controllers.MissionsController`
|
||||
|
||||
**File**: `Controllers/MissionsController.cs`
|
||||
|
||||
> **NOTE (forward-looking)**: post-rename + post-route-change. Today's source is `Controllers/FlightsController.cs` mounted at `[Route("flights")]` with nested waypoint routes under `/flights/{id}/waypoints/...`. Renames + route changes tracked under Jira AZ-EPIC children B6 + B8.
|
||||
|
||||
## Purpose
|
||||
|
||||
REST surface for the `missions` resource AND its nested `waypoints` sub-resource. Wraps `MissionService` for the parent and `WaypointService` for the nested routes.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### Missions
|
||||
|
||||
| HTTP | Route | Action | Body / Query | Returns |
|
||||
|------|-------|--------|--------------|---------|
|
||||
| `POST` | `/missions` | `Create` | `CreateMissionRequest` | `201` + `Location: /missions/{id}`, body: `Mission` |
|
||||
| `PUT` | `/missions/{id:guid}` | `Update` | `UpdateMissionRequest` | `200`, body: `Mission` |
|
||||
| `GET` | `/missions/{id:guid}` | `Get` | -- | `200`, body: `Mission` |
|
||||
| `GET` | `/missions` | `GetAll` | `GetMissionsQuery` (`Name?`, `FromDate?`, `ToDate?`, `Page=1`, `PageSize=20`) | `200`, body: `PaginatedResponse<Mission>` |
|
||||
| `DELETE` | `/missions/{id:guid}` | `Delete` | -- | `204` |
|
||||
|
||||
### Waypoints (nested under a mission)
|
||||
|
||||
| HTTP | Route | Action | Body | Returns |
|
||||
|------|-------|--------|------|---------|
|
||||
| `POST` | `/missions/{id:guid}/waypoints` | `CreateWaypoint` | `CreateWaypointRequest` | `201` + `Location: /missions/{id}/waypoints/{wpId}`, body: `Waypoint` |
|
||||
| `PUT` | `/missions/{id:guid}/waypoints/{waypointId:guid}` | `UpdateWaypoint` | `UpdateWaypointRequest` | `200`, body: `Waypoint` |
|
||||
| `DELETE` | `/missions/{id:guid}/waypoints/{waypointId:guid}` | `DeleteWaypoint` | -- | `204` |
|
||||
| `GET` | `/missions/{id:guid}/waypoints` | `GetWaypoints` | -- | `200`, body: `List<Waypoint>` (no pagination) |
|
||||
|
||||
Class-level decorators: `[ApiController]`, `[Route("missions")]`, `[Authorize(Policy = "FL")]`.
|
||||
|
||||
## Internal Logic
|
||||
|
||||
Same pattern as `VehiclesController`: each action awaits the appropriate service method and wraps in `Created` / `Ok` / `NoContent`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `MissionService`, `WaypointService` (constructor-injected; primary constructor)
|
||||
- `Azaion.Missions.DTOs`
|
||||
|
||||
## Consumers
|
||||
|
||||
- HTTP clients.
|
||||
- Cross-service callers in the suite: `autopilot` reads `GET /missions/{id}` + `GET /missions/{id}/waypoints` to drive UAV behavior; `ui` paginates `/missions`. Both will need to be updated to the new prefix as part of B11 (consumer updates).
|
||||
|
||||
## Data Models
|
||||
|
||||
Returns `Mission` (which has `Vehicle?` + `List<Waypoint>` association properties) and `Waypoint` (with `Mission?` association property) directly. Whether associations are populated on the wire depends on LinqToDB query behavior -- by default, `FirstOrDefaultAsync(predicate)` does NOT eager-load associations, so `mission.Vehicle` and `mission.Waypoints` will serialize as `null`/empty in JSON. Verify in Step 4 against actual API responses if available.
|
||||
|
||||
## Configuration / External Integrations
|
||||
|
||||
None directly.
|
||||
|
||||
## Security
|
||||
|
||||
- All routes behind `[Authorize(Policy = "FL")]`.
|
||||
- Composite-key handling in waypoint operations means a stolen waypoint id alone is not enough -- the attacker must also know the parent mission id.
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
1. **Inconsistent listing pagination** -- `GET /missions` paginates, `GET /missions/{id}/waypoints` and `GET /vehicles` do not. Verification flag.
|
||||
2. **Nested resource modeling** -- waypoints are exposed only as a sub-resource of a mission, never as `/waypoints/{id}`. Consistent with the data model (`mission_id` is `NOT NULL`).
|
||||
3. **`Update` of a mission allows changing `vehicle_id`** but the controller doesn't reflect any business constraint (e.g., immutable after start). All such constraints would need to live in the service.
|
||||
4. **No bulk endpoints** -- no batch create / batch delete for waypoints despite the natural use case ("upload a route plan").
|
||||
5. **Entity body PascalCase wire shape** -- the whole API has no `JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase`, so `Mission`, `Waypoint`, and `PaginatedResponse<Mission>` responses serialize PascalCase property names. Spec says camelCase (per `../../suite/_docs/00_top_level_architecture.md`). Note: the global error envelope produced by `ErrorHandlingMiddleware` is already camelCase (anonymous object literal) -- this divergence applies only to entity / DTO bodies (see `middleware.md` Notes #1–#2 for the distinction). Carry to verification log.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Module: `Azaion.Missions.Controllers.VehiclesController`
|
||||
|
||||
**File**: `Controllers/VehiclesController.cs`
|
||||
|
||||
> **NOTE (forward-looking)**: post-rename. Today's source is `Controllers/AircraftsController.cs` mounted at `[Route("aircrafts")]`. Renames + route changes tracked under Jira AZ-EPIC children B6 (domain rename) and B8 (HTTP route prefix rename).
|
||||
|
||||
## Purpose
|
||||
|
||||
REST surface for the `vehicles` resource. Thin HTTP wrapper over `VehicleService` -- every action delegates 1:1 with no extra logic.
|
||||
|
||||
## Public Interface
|
||||
|
||||
| HTTP | Route | Action | Body / Query | Returns |
|
||||
|------|-------|--------|--------------|---------|
|
||||
| `POST` | `/vehicles` | `Create` | body: `CreateVehicleRequest` | `201 Created` + `Location: /vehicles/{id}`, body: `Vehicle` |
|
||||
| `PUT` | `/vehicles/{id:guid}` | `Update` | body: `UpdateVehicleRequest` | `200 OK`, body: `Vehicle` |
|
||||
| `DELETE` | `/vehicles/{id:guid}` | `Delete` | -- | `204 No Content` |
|
||||
| `GET` | `/vehicles` | `GetAll` | query: `GetVehiclesQuery` (`Name?`, `IsDefault?`) | `200 OK`, body: `List<Vehicle>` (no pagination) |
|
||||
| `GET` | `/vehicles/{id:guid}` | `Get` | -- | `200 OK`, body: `Vehicle` |
|
||||
| `PATCH` | `/vehicles/{id:guid}/default` | `SetDefault` | body: `SetDefaultRequest` | `204 No Content` |
|
||||
|
||||
Class-level decorators:
|
||||
- `[ApiController]` -- automatic 400 for model-binding/validation errors (note: there are no validation attributes, so this rarely triggers).
|
||||
- `[Route("vehicles")]` -- base path.
|
||||
- `[Authorize(Policy = "FL")]` -- every action requires the `FL` JWT permission claim.
|
||||
|
||||
## Internal Logic
|
||||
|
||||
Each action is a one-liner: await the service, return `Created/Ok/NoContent`.
|
||||
|
||||
`Create` returns the persisted entity (including server-generated `Id`).
|
||||
`Update`, `Get`, `GetAll` return entities directly (no DTO mapping -- the entity IS the response shape).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `Azaion.Missions.Services.VehicleService` (constructor-injected)
|
||||
- `Azaion.Missions.DTOs` (request/query types)
|
||||
- ASP.NET Core MVC: `ControllerBase`, `[ApiController]`, `[Route]`, `[Authorize]`, route-binding attributes.
|
||||
|
||||
## Consumers
|
||||
|
||||
- HTTP clients (frontend, other services, Swagger UI, integration tests).
|
||||
|
||||
## Data Models
|
||||
|
||||
Returns the `Vehicle` entity directly on the wire -- fields are serialized as PascalCase properties (`System.Text.Json` default; no camelCase configuration is set in `Program.cs`).
|
||||
|
||||
## Configuration
|
||||
|
||||
None directly.
|
||||
|
||||
## External Integrations
|
||||
|
||||
None directly -- service does the DB work.
|
||||
|
||||
## Security
|
||||
|
||||
- Every action gated by `Policy = "FL"` (JWT claim `permissions = FL`).
|
||||
- No anti-CSRF (REST API, JWT auth -- typical).
|
||||
- No rate limiting at this layer.
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
1. **Entity leakage on the wire** -- controllers return `Vehicle` entities. For `Vehicle` there are no associations, so no over-fetch happens. (Compare to `MissionsController` which returns `Mission` -- that DOES have `Vehicle` and `List<Waypoint>` associations; lazy-load behavior depends on LinqToDB defaults.)
|
||||
2. **No HEAD / OPTIONS** explicit handlers -- relies on framework defaults.
|
||||
3. **`PATCH` for SetDefault** is semantically a partial update -- appropriate. Body is a tiny `{ IsDefault: bool }` dedicated DTO.
|
||||
4. **`Created` body includes the entity** -- consistent with REST best practice (avoids a follow-up GET).
|
||||
@@ -0,0 +1,96 @@
|
||||
# Module: `Azaion.Missions.Database` (connection + migrator)
|
||||
|
||||
**Files (2)**: `Database/AppDataConnection.cs`, `Database/DatabaseMigrator.cs`
|
||||
|
||||
> **NOTE (forward-looking)**: post-rename + post-GPS-Denied-removal state. Today's source still has `Azaion.Flights.Database` namespace, exposes `Orthophotos` / `GpsCorrections` `ITable`s, and migrates 6 tables. After Jira AZ-EPIC children B5 (namespace), B7 (GPS-Denied removal), and B9 (DB migration) land, the shape described here is what stands.
|
||||
|
||||
## Purpose
|
||||
|
||||
- `AppDataConnection` -- single LinqToDB `DataConnection` that exposes one `ITable<T>` property per persisted entity. Acts as the unit-of-work + query root for all services.
|
||||
- `DatabaseMigrator` -- startup-time idempotent schema bootstrap. Issues a single multi-statement SQL block of `CREATE TABLE IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS` against the connection.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### AppDataConnection
|
||||
|
||||
```csharp
|
||||
public class AppDataConnection(DataOptions options) : DataConnection(options) {
|
||||
public ITable<Vehicle> Vehicles => GetTable<Vehicle>();
|
||||
public ITable<Mission> Missions => GetTable<Mission>();
|
||||
public ITable<Waypoint> Waypoints => GetTable<Waypoint>();
|
||||
public ITable<MapObject> MapObjects => GetTable<MapObject>();
|
||||
public ITable<Media> Media => GetTable<Media>(); // schema owned by `annotations`
|
||||
public ITable<Annotation> Annotations => GetTable<Annotation>(); // schema owned by `annotations`
|
||||
public ITable<Detection> Detections => GetTable<Detection>(); // schema owned by detection pipeline
|
||||
}
|
||||
```
|
||||
|
||||
### DatabaseMigrator
|
||||
|
||||
```csharp
|
||||
public static class DatabaseMigrator {
|
||||
public static void Migrate(AppDataConnection db); // synchronous; runs once at startup
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Logic
|
||||
|
||||
### AppDataConnection
|
||||
|
||||
- Inherits LinqToDB's `DataConnection`. Constructor parameter `DataOptions` is built in `Program.cs` via `new DataOptions().UsePostgreSQL(connectionString)`.
|
||||
- Each `ITable<T>` property is computed via `GetTable<T>()` on every access -- cheap query-root handles, not cached state.
|
||||
- Lifetime is **scoped** (registered via `builder.Services.AddScoped`), so each HTTP request gets its own `DataConnection` (and underlying Npgsql connection from the pool).
|
||||
|
||||
### DatabaseMigrator
|
||||
|
||||
- `Migrate(db)` calls `db.Execute(Sql)` where `Sql` is a single string literal containing:
|
||||
- 4 `CREATE TABLE IF NOT EXISTS` statements: `vehicles`, `missions`, `waypoints`, `map_objects`.
|
||||
- 3 `CREATE INDEX IF NOT EXISTS` statements on the foreign-key columns: `ix_missions_vehicle_id`, `ix_waypoints_mission_id`, `ix_map_objects_mission_id`.
|
||||
- Foreign-key constraints declared inline via `REFERENCES`:
|
||||
- `missions.vehicle_id REFERENCES vehicles(id)`
|
||||
- `waypoints.mission_id REFERENCES missions(id)`
|
||||
- `map_objects.mission_id REFERENCES missions(id)`
|
||||
- Defaults: enums default to `0`, decimals to `0`, booleans to `FALSE`, timestamps to `NOW()`.
|
||||
- **Tables intentionally NOT in this migrator**: `media`, `annotations`, `detection`. These are exposed by `AppDataConnection` and consumed by services (delete cascades), but their schema is owned by other suite components (`annotations` migrates `media` + `annotations`; the detection pipeline owns `detection`). All edge-tier services share one local PostgreSQL on the device, so `missions` can read/delete from those tables without owning their DDL.
|
||||
- **Tables removed from this migrator (per Jira B7 + B9)**: `orthophotos`, `gps_corrections`. These are now owned by the separate `gps-denied` service (per `../../suite/_docs/11_gps_denied.md`). Migration B9 includes a one-shot `DROP TABLE IF EXISTS orthophotos; DROP TABLE IF EXISTS gps_corrections;` for fielded edge devices that previously ran the legacy schema.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `LinqToDB`, `LinqToDB.Data`
|
||||
- `Azaion.Missions.Database.Entities` (all 7 entity types)
|
||||
|
||||
No upward dependencies.
|
||||
|
||||
## Consumers
|
||||
|
||||
- `Program.cs` -- registers `AppDataConnection` as scoped, then resolves it once via `app.Services.CreateScope()` to call `DatabaseMigrator.Migrate(db)` before request handling starts.
|
||||
- `Services.VehicleService`, `Services.MissionService`, `Services.WaypointService` -- all take `AppDataConnection` via primary-constructor injection.
|
||||
|
||||
## Data Models
|
||||
|
||||
See `modules/entities.md` for column-level shape; see the SQL block in `DatabaseMigrator.Sql` for the authoritative DDL of the 4 owned tables.
|
||||
|
||||
## Configuration
|
||||
|
||||
`AppDataConnection` itself reads no env vars; the connection string is supplied by the DI registration in `Program.cs`.
|
||||
|
||||
## External Integrations
|
||||
|
||||
- PostgreSQL via Npgsql `10.0.2`.
|
||||
|
||||
## Security
|
||||
|
||||
- No SQL injection surface -- all services use LINQ expression trees (parameterized).
|
||||
- The startup migrator runs DDL using whatever permissions the connection has. Production deployments where the app's user lacks `CREATE TABLE` would fail at startup.
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
1. **Migrator is intentionally scoped to this service's owned tables** (4 mission-attached tables). The 3 cross-service tables (`media`, `annotations`, `detection`) are migrated by their owning services into the same shared local PostgreSQL. Confirmed against `../../suite/_docs/00_top_level_architecture.md` (Database Topology) and `../../suite/_docs/01_annotations.md`.
|
||||
2. **No schema versioning** -- `IF NOT EXISTS` is forward-only, additive only. Column drops, type changes, or constraint changes require either manual SQL or a real migration tool. The B9 `DROP TABLE` block is a one-time exception for the GPS-Denied removal.
|
||||
3. **Synchronous DDL at startup blocks the host until completion**. For an empty DB this is microseconds; for a contended DB it's negligible. Acceptable for a small service.
|
||||
4. **No transaction wrapping** -- the multi-statement `Execute` runs in PostgreSQL's implicit autocommit per statement (LinqToDB doesn't open a transaction unless you ask). All `IF NOT EXISTS` statements are individually idempotent, so partial failure leaves a partially-created schema; next startup completes it.
|
||||
5. **No `LOWER(...)` indexes** for case-insensitive name searches in `vehicles` / `missions`. Likely fine for current scale.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Module: `Azaion.Missions.DTOs`
|
||||
|
||||
**Files (15)** -- grouped by concern:
|
||||
|
||||
| Group | Files |
|
||||
|-------|-------|
|
||||
| Shared value objects | `GeoPoint.cs` |
|
||||
| Shared response wrappers | `PaginatedResponse.cs`, `ErrorResponse.cs` |
|
||||
| Vehicle requests/queries | `CreateVehicleRequest.cs`, `UpdateVehicleRequest.cs`, `GetVehiclesQuery.cs`, `SetDefaultRequest.cs` |
|
||||
| Mission requests/queries | `CreateMissionRequest.cs`, `UpdateMissionRequest.cs`, `GetMissionsQuery.cs` |
|
||||
| Waypoint requests | `CreateWaypointRequest.cs`, `UpdateWaypointRequest.cs` |
|
||||
|
||||
> **NOTE (forward-looking)**: post-rename file names. Today's source has `CreateAircraftRequest.cs` / `CreateFlightRequest.cs` / etc. Renames tracked under Jira AZ-EPIC child B6.
|
||||
|
||||
## Purpose
|
||||
|
||||
HTTP request/response/query payloads for the controller layer. Plain POCOs with public mutable properties -- no validation attributes, no record types.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### Shared
|
||||
|
||||
```csharp
|
||||
public class GeoPoint {
|
||||
public decimal? Lat { get; set; }
|
||||
public decimal? Lon { get; set; }
|
||||
public string? Mgrs { get; set; } // Military Grid Reference System
|
||||
}
|
||||
|
||||
public class PaginatedResponse<T> {
|
||||
public List<T> Items { get; set; } = [];
|
||||
public int TotalCount { get; set; }
|
||||
public int Page { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
}
|
||||
|
||||
public class ErrorResponse {
|
||||
public int StatusCode { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public List<string>? Errors { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Vehicle
|
||||
|
||||
```csharp
|
||||
public class CreateVehicleRequest {
|
||||
public VehicleType Type { get; set; } // Plane | Copter | UGV | GuidedMissile
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public FuelType FuelType { get; set; }
|
||||
public decimal BatteryCapacity { get; set; }
|
||||
public decimal EngineConsumption { get; set; }
|
||||
public decimal EngineConsumptionIdle { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateVehicleRequest {
|
||||
// All properties nullable -- partial-update semantics, applied per non-null field
|
||||
public VehicleType? Type;
|
||||
public string? Model;
|
||||
public string? Name;
|
||||
public FuelType? FuelType;
|
||||
public decimal? BatteryCapacity;
|
||||
public decimal? EngineConsumption;
|
||||
public decimal? EngineConsumptionIdle;
|
||||
public bool? IsDefault;
|
||||
}
|
||||
|
||||
public class GetVehiclesQuery {
|
||||
public string? Name { get; set; }
|
||||
public bool? IsDefault { get; set; }
|
||||
}
|
||||
|
||||
public class SetDefaultRequest {
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Mission
|
||||
|
||||
```csharp
|
||||
public class CreateMissionRequest {
|
||||
public Guid VehicleId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime? CreatedDate { get; set; } // Defaults to UtcNow if null
|
||||
}
|
||||
|
||||
public class UpdateMissionRequest {
|
||||
public string? Name { get; set; }
|
||||
public Guid? VehicleId { get; set; }
|
||||
}
|
||||
|
||||
public class GetMissionsQuery {
|
||||
public string? Name { get; set; }
|
||||
public DateTime? FromDate { get; set; }
|
||||
public DateTime? ToDate { get; set; }
|
||||
public int Page { get; set; } = 1;
|
||||
public int PageSize { get; set; } = 20;
|
||||
}
|
||||
```
|
||||
|
||||
### Waypoint
|
||||
|
||||
```csharp
|
||||
public class CreateWaypointRequest {
|
||||
public GeoPoint? GeoPoint { get; set; }
|
||||
public WaypointSource WaypointSource { get; set; }
|
||||
public WaypointObjective WaypointObjective { get; set; }
|
||||
public int OrderNum { get; set; }
|
||||
public decimal Height { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateWaypointRequest { // identical shape to Create
|
||||
public GeoPoint? GeoPoint { get; set; }
|
||||
public WaypointSource WaypointSource { get; set; }
|
||||
public WaypointObjective WaypointObjective { get; set; }
|
||||
public int OrderNum { get; set; }
|
||||
public decimal Height { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Logic
|
||||
|
||||
Pure data containers. No methods, no constructors beyond the implicit default.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `Azaion.Missions.Enums` -- for typed enum properties
|
||||
|
||||
## Consumers
|
||||
|
||||
- `Services.VehicleService`, `Services.MissionService`, `Services.WaypointService` -- request DTOs as method parameters; `PaginatedResponse<Mission>` returned from `MissionService.GetMissions`.
|
||||
- `Controllers.VehiclesController`, `Controllers.MissionsController` -- `[FromBody]` / `[FromQuery]` binding.
|
||||
|
||||
## Data Models
|
||||
|
||||
Mirror the corresponding entity columns minus identity/timestamps (those are server-assigned).
|
||||
|
||||
## Configuration / External Integrations / Security
|
||||
|
||||
None directly -- all binding is provided by ASP.NET Core model binding with no custom validators or `[Required]` / `[Range]` attributes.
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
- **No validation**: nothing prevents `CreateVehicleRequest.Name = ""`, negative `BatteryCapacity`, `OrderNum < 0`, or out-of-range enum values (binding on int will accept any int and persist it). Carry to AC / restrictions in Step 6.
|
||||
- **`UpdateWaypointRequest` is structurally identical to `CreateWaypointRequest`** but uses non-nullable enum/numeric fields, meaning every `PUT` overwrites all fields -- no partial-update semantics for waypoints (unlike vehicle, which uses `?` everywhere). Inconsistency between resources.
|
||||
- **`PaginatedResponse<T>`** is only used for `Mission` listing; `GetVehicles` returns a plain `List<Vehicle>` (no pagination, no total count), even though `GetVehiclesQuery` exists. Inconsistent listing contract -- matches spec (vehicles are a small dataset).
|
||||
- **`ErrorResponse`** is defined but not used by `ErrorHandlingMiddleware` (which writes an anonymous object literal). Dead code candidate.
|
||||
- `GeoPoint` allows all-null (Lat, Lon, Mgrs all optional). No invariant ensures at least one location representation is populated.
|
||||
- **Spec divergence (Geopoint)**: `../../suite/_docs/02_missions.md` and `../../suite/_docs/00_database_schema.md` define `Waypoints.GPS` as a single `string GPS` field with auto-conversion (`Lat <-> MGRS`). Code stores three separate columns (`lat NUMERIC`, `lon NUMERIC`, `mgrs TEXT`) and exposes them as three flat properties without conversion logic. Carry to verification log.
|
||||
- **Spec divergence (ErrorResponse)**: spec `errors` is `object?` keyed by field name (per-field validation arrays). Code defines `List<string>? Errors` and the type is unused on the wire (middleware emits an anonymous object instead). Carry to verification log.
|
||||
- **Spec divergence (PaginatedResponse case-style)**: suite-wide standard is camelCase (`items`, `totalCount`, `page`, `pageSize`). `PaginatedResponse<T>` declares PascalCase properties and System.Text.Json's defaults preserve them, so on-the-wire output is `{"Items":..., "TotalCount":..., ...}`. No `JsonNamingPolicy.CamelCase` is configured.
|
||||
- **Spec partial conformance (ErrorResponse / error envelope)**: the static `ErrorResponse` DTO is **unused on the wire** -- `Middleware.ErrorHandlingMiddleware` writes an anonymous object literal instead. That anonymous object happens to use lowercase property names (`statusCode`, `message`), which `System.Text.Json` preserves, so the live error envelope IS camelCase (matching spec on case) but still missing the spec's `errors: object?` field. Were `ErrorResponse` ever used directly it would emit PascalCase (`StatusCode`, `Message`, `Errors`) and additionally have the wrong `Errors` shape (`List<string>?` vs spec's `object?`). Two carry-forward concerns: add the `errors` field to the live envelope, and either remove the dead `ErrorResponse` DTO or fix it to match spec.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Module: `Azaion.Missions.Database.Entities`
|
||||
|
||||
**Files (7)**: `Vehicle.cs`, `Mission.cs`, `Waypoint.cs`, `MapObject.cs`, `Media.cs`, `Annotation.cs`, `Detection.cs`
|
||||
|
||||
> **NOTE (forward-looking)**: this doc reflects the post-rename state. Today's source still has `Aircraft.cs`, `Flight.cs`, `Orthophoto.cs`, `GpsCorrection.cs`. The renames + GPS-Denied removal are tracked under Jira AZ-EPIC children B6 (domain rename) and B7 (GPS-Denied removal).
|
||||
|
||||
## Purpose
|
||||
|
||||
LinqToDB row-mapping classes. Each entity uses `[Table("snake_case_name")]` + `[Column("snake_case")]` + `[PrimaryKey]` to map to a PostgreSQL table. Two entities (`Mission`, `Waypoint`) include `[Association]`-based navigation; the rest are flat row maps.
|
||||
|
||||
## Public Interface
|
||||
|
||||
### Vehicle (table `vehicles`)
|
||||
|
||||
```csharp
|
||||
[Table("vehicles")]
|
||||
public class Vehicle {
|
||||
[PrimaryKey, Column("id")] public Guid Id;
|
||||
[Column("type")] public VehicleType Type; // Plane | Copter | UGV | GuidedMissile
|
||||
[Column("model")] public string Model = "";
|
||||
[Column("name")] public string Name = "";
|
||||
[Column("fuel_type")] public FuelType FuelType;
|
||||
[Column("battery_capacity")] public decimal BatteryCapacity;
|
||||
[Column("engine_consumption")] public decimal EngineConsumption;
|
||||
[Column("engine_consumption_idle")] public decimal EngineConsumptionIdle;
|
||||
[Column("is_default")] public bool IsDefault;
|
||||
}
|
||||
```
|
||||
|
||||
### Mission (table `missions`)
|
||||
|
||||
```csharp
|
||||
[Table("missions")]
|
||||
public class Mission {
|
||||
[PrimaryKey, Column("id")] public Guid Id;
|
||||
[Column("created_date")] public DateTime CreatedDate;
|
||||
[Column("name")] public string Name = "";
|
||||
[Column("vehicle_id")] public Guid VehicleId;
|
||||
|
||||
[Association(ThisKey=VehicleId, OtherKey=Vehicle.Id)] public Vehicle? Vehicle;
|
||||
[Association(ThisKey=Id, OtherKey=Waypoint.MissionId)] public List<Waypoint> Waypoints = [];
|
||||
}
|
||||
```
|
||||
|
||||
### Waypoint (table `waypoints`)
|
||||
|
||||
```csharp
|
||||
[Table("waypoints")]
|
||||
public class Waypoint {
|
||||
[PrimaryKey, Column("id")] public Guid Id;
|
||||
[Column("mission_id")] public Guid MissionId;
|
||||
[Column("lat")] public decimal? Lat;
|
||||
[Column("lon")] public decimal? Lon;
|
||||
[Column("mgrs")] public string? Mgrs;
|
||||
[Column("waypoint_source")] public WaypointSource WaypointSource;
|
||||
[Column("waypoint_objective")] public WaypointObjective WaypointObjective;
|
||||
[Column("order_num")] public int OrderNum;
|
||||
[Column("height")] public decimal Height;
|
||||
|
||||
[Association(ThisKey=MissionId, OtherKey=Mission.Id)] public Mission? Mission;
|
||||
}
|
||||
```
|
||||
|
||||
### MapObject (table `map_objects`)
|
||||
|
||||
```csharp
|
||||
[Table("map_objects")]
|
||||
public class MapObject {
|
||||
[PrimaryKey, Column("id")] public Guid Id;
|
||||
[Column("mission_id")] public Guid MissionId;
|
||||
[Column("h3_index")] public string H3Index = ""; // Uber H3 hex grid
|
||||
[Column("mgrs")] public string Mgrs = "";
|
||||
[Column("lat")] public decimal? Lat;
|
||||
[Column("lon")] public decimal? Lon;
|
||||
[Column("class_num")] public int ClassNum;
|
||||
[Column("label")] public string Label = "";
|
||||
[Column("size_width_m")] public decimal SizeWidthM;
|
||||
[Column("size_length_m")] public decimal SizeLengthM;
|
||||
[Column("confidence")] public decimal Confidence;
|
||||
[Column("object_status")] public ObjectStatus ObjectStatus;
|
||||
[Column("first_seen_at")] public DateTime FirstSeenAt;
|
||||
[Column("last_seen_at")] public DateTime LastSeenAt;
|
||||
}
|
||||
```
|
||||
|
||||
### Media / Annotation / Detection (cross-service stubs -- see "Notes / Smells")
|
||||
|
||||
```csharp
|
||||
[Table("media")]
|
||||
public class Media {
|
||||
[PrimaryKey, Column("id")] public string Id = ""; // TEXT primary key
|
||||
[Column("waypoint_id")] public Guid? WaypointId;
|
||||
}
|
||||
|
||||
[Table("annotations")]
|
||||
public class Annotation {
|
||||
[PrimaryKey, Column("id")] public string Id = ""; // TEXT
|
||||
[Column("media_id")] public string MediaId = ""; // TEXT FK to media.id
|
||||
}
|
||||
|
||||
[Table("detection")] // SINGULAR table name -- diverges from every other entity (owned by detection pipeline)
|
||||
public class Detection {
|
||||
[PrimaryKey, Column("id")] public Guid Id;
|
||||
[Column("annotation_id")] public string AnnotationId = "";
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Logic
|
||||
|
||||
Pure POCOs. The only behavior comes from LinqToDB attribute mapping (`[Table]`, `[Column]`, `[PrimaryKey]`, `[Association]`).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `LinqToDB.Mapping` (NuGet)
|
||||
- `Azaion.Missions.Enums` (for `Vehicle`, `Waypoint`, `MapObject`)
|
||||
|
||||
## Consumers
|
||||
|
||||
- `Database.AppDataConnection` -- exposes `ITable<T>` for each entity.
|
||||
- `Database.DatabaseMigrator` -- implicitly (defines DDL for the same names; does NOT reference entity types).
|
||||
- `Services.VehicleService`, `Services.MissionService`, `Services.WaypointService` -- entity types are returned/constructed.
|
||||
|
||||
## Data Model Highlights
|
||||
|
||||
```
|
||||
vehicles --< missions --< waypoints --? media --< annotations --< detection
|
||||
\--< map_objects
|
||||
```
|
||||
|
||||
- `Mission` is the central aggregate root: most domain rows hang off `mission_id`.
|
||||
- `Waypoint` is a sub-aggregate of `Mission` and is the join point for `Media`.
|
||||
- `MapObject` is detection output (class_num + confidence + spatial index) tied to a mission, NOT to a specific waypoint. **Schema is owned by this service, but rows are written by `autopilot`** (per `../../suite/_docs/06_autopilot_design.md`).
|
||||
|
||||
## Cross-service stubs
|
||||
|
||||
`Media`, `Annotation`, `Detection` are intentional read-only stubs -- only `Id` and one foreign key each. They're queried/deleted by `MissionService.DeleteMission` and `WaypointService.DeleteWaypoint` but never written by this service. Schema-wise they are owned by other suite components (`annotations` for `media` + `annotations`, the detection pipeline for `detection`), per `../../suite/_docs/00_top_level_architecture.md` and `../../suite/_docs/01_annotations.md`. The shared edge-PostgreSQL pattern means each service migrates its own tables but all services see the full schema.
|
||||
|
||||
These three are deliberately NOT in `DatabaseMigrator.Sql` -- their schema is created by the owning services on the same shared local PostgreSQL.
|
||||
|
||||
## Configuration / External Integrations / Security
|
||||
|
||||
None directly. Persistence is delegated to LinqToDB + Npgsql.
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
1. **`Detection` table singular** while `vehicles`, `missions`, `waypoints`, `map_objects`, `media`, `annotations` are plural. Owned by another service -- naming is THEIR call to make consistent.
|
||||
2. **Mixed PK types**: `Vehicle`, `Mission`, `Waypoint`, `MapObject`, `Detection` use `Guid`; `Media`, `Annotation` use `string` (TEXT, XxHash64-based per `../../suite/_docs/00_database_schema.md`).
|
||||
3. **No domain methods** -- all business rules (e.g., the "default vehicle" exclusivity in `VehicleService`) live in services, not in the entities. Consistent and intentional for a thin-data-model approach.
|
||||
4. **`Media.WaypointId` is nullable** while every other foreign key here is not. Suggests `Media` can attach to a non-waypoint context (e.g., mission-level media); enforcement is on `annotations`'s side.
|
||||
5. **Geopoint divergence (carry to verification log)**: spec stores `Waypoints.GPS` as a single `string GPS` field with `Lat <-> MGRS` auto-conversion (per `../../suite/_docs/02_missions.md` and `../../suite/_docs/00_database_schema.md`). Code splits it into 3 separate columns. Resolution lives outside the GPS-Denied removal scope -- carry forward.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Module: `Azaion.Missions.Enums`
|
||||
|
||||
**Files (5)**: `Enums/VehicleType.cs`, `Enums/FuelType.cs`, `Enums/WaypointSource.cs`, `Enums/WaypointObjective.cs`, `Enums/ObjectStatus.cs`
|
||||
|
||||
> **NOTE (forward-looking)**: post-rename + post-multi-vehicle-support state. Today's source has `Enums/AircraftType.cs` with `Plane = 0`, `Copter = 1`. The expanded `VehicleType` is tracked under Jira AZ-EPIC child B6.
|
||||
|
||||
## Purpose
|
||||
|
||||
Domain enumerations used by entities and DTOs. All values are stored in PostgreSQL as `INTEGER NOT NULL DEFAULT 0` (per `DatabaseMigrator`).
|
||||
|
||||
## Public Interface
|
||||
|
||||
| Enum | Members (value) |
|
||||
|------|-----------------|
|
||||
| `VehicleType` | `Plane = 0`, `Copter = 1`, `UGV = 2`, `GuidedMissile = 3` |
|
||||
| `FuelType` | `Electric = 0`, `Gasoline = 1`, `Diesel = 2` |
|
||||
| `WaypointSource` | `Auto = 0`, `Manual = 1` |
|
||||
| `WaypointObjective` | `Surveillance = 0`, `Strike = 1`, `Recon = 2` |
|
||||
| `ObjectStatus` | `New = 0`, `Moved = 1`, `Removed = 2` |
|
||||
|
||||
## Internal Logic
|
||||
|
||||
None -- pure value lists. No `[Flags]`, no custom backing storage.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None (no `using` of internal namespaces).
|
||||
|
||||
## Consumers
|
||||
|
||||
- `Database.Entities.Vehicle` -- `VehicleType`, `FuelType`
|
||||
- `Database.Entities.Waypoint` -- `WaypointSource`, `WaypointObjective`
|
||||
- `Database.Entities.MapObject` -- `ObjectStatus`
|
||||
- `DTOs.CreateVehicleRequest`, `DTOs.UpdateVehicleRequest` -- `VehicleType`, `FuelType`
|
||||
- `DTOs.CreateWaypointRequest`, `DTOs.UpdateWaypointRequest` -- `WaypointSource`, `WaypointObjective`
|
||||
- `Services.WaypointService` -- `using` for symbol resolution; runtime use is via DTO/entity properties
|
||||
|
||||
## Data Models
|
||||
|
||||
The enums themselves; persisted as `INTEGER` columns (see `Database/DatabaseMigrator.cs`).
|
||||
|
||||
## Configuration / External Integrations / Security
|
||||
|
||||
None.
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
- **`VehicleType` covers UAV (Plane / Copter), UGV, and GuidedMissile** -- aligns with the broader fleet described in `../../../hardware/_standalone/target_acquisition/target_acquisition.md`. The previous `AircraftType` name was too narrow and excluded ground / loitering-munition vehicles.
|
||||
- **`FuelType` may not fit `GuidedMissile`** -- a single-use missile has no battery/engine-consumption profile in the same sense. Carry forward as Phase C decision (Jira open: do we add `None` / make `FuelType` nullable?).
|
||||
- The `WaypointObjective` set (`Surveillance`, `Strike`, `Recon`) covers the mission-level intent for any vehicle class.
|
||||
- Integer-based persistence is positional; reordering or removing a member would silently corrupt existing rows. **Adding new members at the end (UGV = 2, GuidedMissile = 3) is safe** -- existing rows keep `Type = 0/1`.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Module: `Azaion.Missions.Middleware`
|
||||
|
||||
> **NOTE (forward-looking)**: post-rename namespace. Today's source still lives under `Azaion.Flights.Middleware`. Renames tracked under Jira AZ-EPIC child B5.
|
||||
|
||||
**Files (1)**: `Middleware/ErrorHandlingMiddleware.cs`
|
||||
|
||||
## Purpose
|
||||
|
||||
Global exception → JSON error response mapper. Wraps the rest of the request pipeline and converts a fixed set of exception types to specific HTTP status codes; everything else becomes a 500.
|
||||
|
||||
## Public Interface
|
||||
|
||||
```csharp
|
||||
public class ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> logger) {
|
||||
public Task Invoke(HttpContext context);
|
||||
}
|
||||
```
|
||||
|
||||
Standard ASP.NET Core middleware shape (primary-constructor variant).
|
||||
|
||||
## Internal Logic
|
||||
|
||||
```text
|
||||
try { await next(context); }
|
||||
catch (KeyNotFoundException ex) → 404 NotFound, body: { statusCode, message = ex.Message }
|
||||
catch (ArgumentException ex) → 400 BadRequest, body: { statusCode, message = ex.Message }
|
||||
catch (InvalidOperationException ex) → 409 Conflict, body: { statusCode, message = ex.Message }
|
||||
catch (Exception ex) → 500 InternalServerError, body: { statusCode, message = "Internal server error" }
|
||||
PLUS: logger.LogError(ex, "Unhandled exception");
|
||||
```
|
||||
|
||||
Body is serialized via `JsonSerializer.Serialize(new { statusCode, message })` — an **anonymous object literal**, NOT the `DTOs.ErrorResponse` type. The anonymous-type property names are written lowercase-first in code (`statusCode`, `message`); `System.Text.Json` preserves them as-is when no `JsonNamingPolicy` is configured, so the wire shape is `{"statusCode":..., "message":"..."}` — **camelCase by accidental match with the suite spec**. (The unused `DTOs.ErrorResponse` type, by contrast, declares PascalCase properties and would serialize PascalCase if it were ever used directly.)
|
||||
|
||||
`Content-Type` is set to `application/json`. Response body is written via `WriteAsync` (string).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `System.Net` (`HttpStatusCode`)
|
||||
- `System.Text.Json` (`JsonSerializer`)
|
||||
- ASP.NET Core middleware abstractions (transitive via `Microsoft.NET.Sdk.Web`)
|
||||
- `Microsoft.Extensions.Logging.ILogger<T>` (transitive)
|
||||
|
||||
No internal dependencies.
|
||||
|
||||
## Consumers
|
||||
|
||||
- `Program.cs` — `app.UseMiddleware<ErrorHandlingMiddleware>();` is called BEFORE `UseCors`, `UseAuthentication`, `UseAuthorization`, `UseSwagger`, `UseSwaggerUI`, `MapControllers`.
|
||||
|
||||
## Configuration / External Integrations / Security
|
||||
|
||||
None.
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
1. **`DTOs.ErrorResponse` is unused here** — middleware writes an anonymous object instead. **Partial spec divergence**: `../../suite/_docs/00_top_level_architecture.md` § Error Response Format mandates `{ "statusCode", "message", "errors": object? }` (camelCase, `errors` is an *object* of per-field arrays). The middleware's anonymous-object output IS already camelCase on case (`{"statusCode":..., "message":"..."}`) but missing the `errors` field; the static `ErrorResponse` DTO has the wrong shape (`List<string>? Errors` instead of `object?`) and is dead code.
|
||||
2. **Entity / DTO body case-style** is PascalCase across the rest of the API (controller responses serialize entities and `PaginatedResponse<T>` via `System.Text.Json` defaults with no naming policy override). The error envelope's accidental camelCase match documented in point 1 does NOT extend to those responses — see `architecture.md` ADR-002.
|
||||
3. **`InvalidOperationException` → 409 Conflict** is a non-standard mapping. The .NET BCL throws this for many "wrong state at the moment" conditions; in this codebase it is used by `VehicleService.DeleteVehicle` to report "vehicle is referenced by missions" (a true 409). But any third-party library throwing `InvalidOperationException` for an unrelated reason would also surface as 409, masking the real cause.
|
||||
4. **Generic 500 swallows the message** (good for security — no internal detail leaked) and logs the exception (good for diagnostics). No correlation ID is included in the response, so production support has to grep logs by timestamp.
|
||||
5. **Order of catches** matters: `ArgumentException` is base of `ArgumentNullException` / `ArgumentOutOfRangeException`, so those also become 400 (correct). `InvalidOperationException` ahead of generic `Exception` is correct.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Module: `Program` (composition root) + `GlobalUsings`
|
||||
|
||||
**Files (2)**: `Program.cs`, `GlobalUsings.cs`
|
||||
|
||||
> **NOTE (forward-looking)**: post-rename. Today's source has `Azaion.Flights` namespace and `dotnet Azaion.Flights.dll` entrypoint. Renames + DLL/image changes tracked under Jira AZ-EPIC children B5 (namespace), B7 (drop GPS policy), B10 (Dockerfile + docker image rename).
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level statements that build the ASP.NET Core web host: read environment, register DI services (DB connection, services, auth, CORS, MVC, Swagger), run the migrator once, then `app.Run()`.
|
||||
|
||||
`GlobalUsings.cs` adds three project-wide `global using` directives so individual files don't need to repeat them:
|
||||
|
||||
```csharp
|
||||
global using LinqToDB;
|
||||
global using LinqToDB.Async;
|
||||
global using LinqToDB.Data;
|
||||
```
|
||||
|
||||
## Public Interface
|
||||
|
||||
`Program.cs` is a top-level program -- it is not a class with a public surface. Its observable contract is the resulting HTTP server:
|
||||
- Listens on the Kestrel-default URL (typically `http://0.0.0.0:8080` in container per Dockerfile `EXPOSE 8080`).
|
||||
- Exposes routes mapped by `MapControllers` (see `controller_vehicles.md`, `controller_missions.md`) plus `GET /health`.
|
||||
- Serves Swagger UI at the default `/swagger` route (not gated on environment).
|
||||
|
||||
## Internal Logic
|
||||
|
||||
```text
|
||||
1. WebApplicationBuilder = WebApplication.CreateBuilder(args)
|
||||
2. Resolve DATABASE_URL (Configuration -> Env -> fallback)
|
||||
If it begins with "postgresql://" -> ConvertPostgresUrl() to Npgsql key=value form.
|
||||
3. Resolve JWT_SECRET (Configuration -> Env -> fallback)
|
||||
4. Register services (scoped where applicable):
|
||||
- AppDataConnection <- scoped, built via new DataOptions().UsePostgreSQL(connectionString)
|
||||
- MissionService, WaypointService, VehicleService <- scoped
|
||||
- AddJwtAuth(jwtSecret) -> JWT bearer + "FL" policy
|
||||
- AddCors with default policy = AllowAnyOrigin/Method/Header
|
||||
- AddControllers, AddEndpointsApiExplorer, AddSwaggerGen
|
||||
5. Build the WebApplication.
|
||||
6. Open a temp scope, resolve AppDataConnection, call DatabaseMigrator.Migrate(db).
|
||||
7. Configure pipeline (order matters):
|
||||
a. UseMiddleware<ErrorHandlingMiddleware>
|
||||
b. UseCors
|
||||
c. UseAuthentication
|
||||
d. UseAuthorization
|
||||
e. UseSwagger, UseSwaggerUI
|
||||
f. MapControllers
|
||||
g. MapGet("/health", () => Results.Ok({status:"healthy"}))
|
||||
8. app.Run()
|
||||
|
||||
ConvertPostgresUrl(url):
|
||||
parses postgresql://user[:pass]@host[:port]/db into
|
||||
"Host={host};Port={port};Database={db};Username={user};Password={pass}"
|
||||
(defaults port to 5432; absent password becomes empty)
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- All internal namespaces: `Azaion.Missions.{Auth, Database, Middleware, Services}`.
|
||||
- ASP.NET Core, LinqToDB, Npgsql, Swashbuckle, JWT bearer (NuGet).
|
||||
|
||||
## Consumers
|
||||
|
||||
- The container runtime (`ENTRYPOINT ["dotnet", "Azaion.Missions.dll"]` in `Dockerfile` after B10).
|
||||
- `dotnet run` for local development.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env / Config Key | Required? | Default |
|
||||
|------------------|-----------|---------|
|
||||
| `DATABASE_URL` | No (has dev fallback) | `Host=localhost;Database=azaion;Username=postgres;Password=changeme` |
|
||||
| `JWT_SECRET` | No (has dev fallback) | `development-secret-key-min-32-chars!!` |
|
||||
| `AZAION_REVISION` | Set by Dockerfile from `CI_COMMIT_SHA` | `unknown` (build arg default) |
|
||||
|
||||
There is **no `appsettings.json`** in this repo (per discovery) -- config comes from env / process variables only. Suite-wide env conventions live in `../../suite/_docs/00_top_level_architecture.md` (Edge compose excerpt).
|
||||
|
||||
## External Integrations
|
||||
|
||||
- PostgreSQL (read/write) via Npgsql.
|
||||
- Identity provider: the suite's `admin` service mints JWTs against the central user PostgreSQL; `JWT_SECRET` is the shared HMAC secret. Local validation only -- no network round-trip per request.
|
||||
|
||||
## Security
|
||||
|
||||
- **Hardcoded fallbacks** for both `DATABASE_URL` and `JWT_SECRET` are dev-only. Production deployments MUST override them; failure to do so silently runs with weak/known credentials.
|
||||
- **CORS is permissive** in all environments (`AllowAnyOrigin/Method/Header`). Combined with JWT auth this is not catastrophic (browser will send the bearer token only if the front-end opts in), but exposes the API to opportunistic browser-based scraping.
|
||||
- **Swagger is unconditionally enabled** -- both the JSON document and the UI are served regardless of environment. Anyone reaching the host can enumerate the API surface.
|
||||
- **No HTTPS redirection** middleware (`UseHttpsRedirection`) -- TLS is assumed to terminate at an upstream reverse proxy.
|
||||
- **`app.UseMiddleware<ErrorHandlingMiddleware>` runs before `UseAuthentication`/`UseAuthorization`** -- auth failures still emit the framework's stock 401/403 (which is fine), but any auth-stage exceptions ALSO run through the global handler (which converts `KeyNotFoundException` -> 404, etc.; auth pipeline doesn't typically throw those).
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
1. **`DATABASE_URL` URL parsing**: `ConvertPostgresUrl` is a small ad-hoc parser. Fine for typical cases but does not URL-decode the user/password. A password containing `@`, `:`, `/`, `%` would break parsing or be interpreted wrong. Carry to verification log.
|
||||
2. **No `IsDevelopment()` checks** anywhere in `Program.cs`. Dev/prod behaviors (Swagger, fallback secrets) are not gated.
|
||||
3. **`AddSwaggerGen()` with no JWT bearer security definition** -- Swagger UI's "Authorize" button won't appear; users must supply tokens via `curl -H "Authorization: Bearer ..."`. Not a bug, but a usability issue.
|
||||
4. **`DatabaseMigrator.Migrate` is fire-and-forget** -- if it throws (DB down at startup), the host process crashes. Acceptable for container orchestration that restarts on failure.
|
||||
5. **`GlobalUsings.cs` imports `LinqToDB.Async`** but most async LINQ extensions used by the project (`AnyAsync`, `FirstOrDefaultAsync`, `ToListAsync`, etc.) actually live in the `LinqToDB` namespace already. Harmless redundancy.
|
||||
6. **Service lifetime**: `AppDataConnection` is **scoped** (per-HTTP-request) -- correct, because `DataConnection` holds a backing Npgsql connection that should not be shared across requests. The three domain services share this scope, so all DB calls within one request go through the same physical connection (good for correctness, no implicit transactions though).
|
||||
@@ -0,0 +1,112 @@
|
||||
# Module: `Azaion.Missions.Services.MissionService`
|
||||
|
||||
**File**: `Services/MissionService.cs`
|
||||
|
||||
> **NOTE (forward-looking)**: post-rename + post-GPS-Denied-removal. Today's source is `Services/FlightService.cs` and its cascade still reaches into `orthophotos` + `gps_corrections`. Renames tracked under Jira AZ-EPIC child B6; cascade shrink under B7.
|
||||
|
||||
## Purpose
|
||||
|
||||
CRUD over the `missions` aggregate plus a manual cascade-delete chain that walks `mission -> waypoints -> media -> annotations -> detection` and clears the `map_objects` side table.
|
||||
|
||||
## Public Interface
|
||||
|
||||
```csharp
|
||||
public class MissionService(AppDataConnection db) {
|
||||
Task<Mission> CreateMission(CreateMissionRequest request);
|
||||
Task<Mission> UpdateMission(Guid id, UpdateMissionRequest request);
|
||||
Task<Mission> GetMission(Guid id);
|
||||
Task<PaginatedResponse<Mission>> GetMissions(GetMissionsQuery query);
|
||||
Task DeleteMission(Guid id);
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Logic
|
||||
|
||||
### `CreateMission`
|
||||
1. Existence check: `db.Vehicles.AnyAsync(v => v.Id == request.VehicleId)`. On miss -> throw `ArgumentException` (mapped to 400 by middleware).
|
||||
2. Build a fresh `Mission`:
|
||||
- `Id = Guid.NewGuid()`
|
||||
- `CreatedDate = request.CreatedDate ?? DateTime.UtcNow`
|
||||
- `Name`, `VehicleId` copied from request.
|
||||
3. `db.InsertAsync(mission)` and return.
|
||||
|
||||
### `UpdateMission`
|
||||
1. Load by id (404 on miss).
|
||||
2. If `request.Name != null` -> assign.
|
||||
3. If `request.VehicleId.HasValue`:
|
||||
- Existence check on the new `VehicleId` (400 on miss).
|
||||
- Assign.
|
||||
4. `db.UpdateAsync(mission)`.
|
||||
|
||||
### `GetMission`
|
||||
- `FirstOrDefaultAsync(m => m.Id == id)` -> 404 on miss.
|
||||
|
||||
### `GetMissions` -- paginated
|
||||
1. Build query on `db.Missions`.
|
||||
2. Filters:
|
||||
- `Name`: case-insensitive `Contains` (`LOWER(name) LIKE %lower%`).
|
||||
- `FromDate`: `created_date >= FromDate`.
|
||||
- `ToDate`: `created_date <= ToDate`.
|
||||
3. Compute `totalCount` via a `CountAsync()` call on the filtered query.
|
||||
4. Fetch the page: `OrderByDescending(CreatedDate).Skip((Page-1)*PageSize).Take(PageSize).ToListAsync()`.
|
||||
5. Return `PaginatedResponse<Mission> { Items, TotalCount, Page, PageSize }`.
|
||||
|
||||
### `DeleteMission` -- manual cascade
|
||||
1. Load the mission (404 on miss).
|
||||
2. Top-level scrub:
|
||||
- `map_objects WHERE mission_id = id` -> `DeleteAsync`.
|
||||
3. Resolve dependents through waypoints:
|
||||
- `waypointIds = SELECT id FROM waypoints WHERE mission_id = id`.
|
||||
- If any waypoints exist:
|
||||
- `mediaIds = SELECT id FROM media WHERE waypoint_id IN (waypointIds)`.
|
||||
- If any media exist:
|
||||
- `annotationIds = SELECT id FROM annotations WHERE media_id IN (mediaIds)`.
|
||||
- If any annotations exist: `DELETE FROM detection WHERE annotation_id IN (annotationIds)`.
|
||||
- `DELETE FROM annotations WHERE media_id IN (mediaIds)`.
|
||||
- `DELETE FROM media WHERE waypoint_id IN (waypointIds)`.
|
||||
4. `DELETE FROM waypoints WHERE mission_id = id`.
|
||||
5. `DELETE FROM missions WHERE id = id`.
|
||||
|
||||
**No transaction wraps the cascade** -- partial failure leaves orphan rows.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `LinqToDB` (extension methods like `AnyAsync`, `DeleteAsync`)
|
||||
- `AppDataConnection`, `Database.Entities.Mission` + 6 other entities used in the cascade
|
||||
- `DTOs.{CreateMissionRequest, UpdateMissionRequest, GetMissionsQuery, PaginatedResponse}`
|
||||
|
||||
## Consumers
|
||||
|
||||
- `Controllers.MissionsController` -- wraps each method 1:1.
|
||||
|
||||
## Data Models
|
||||
|
||||
Reads `vehicles` (existence check). Writes `missions`. On delete, also writes `map_objects`, `media`, `annotations`, `detection`, `waypoints`.
|
||||
|
||||
## Cross-service contract
|
||||
|
||||
`media` / `annotations` / `detection` are owned schema-wise by other edge components (`annotations`, detection pipeline) on the shared local PostgreSQL. `MissionService.DeleteMission` is the canonical place that knows the full mission ownership graph: when a mission is deleted, its derived rows in those tables are scrubbed by THIS service. If those tables are missing on the deployment target, the cascade throws `relation does not exist`; in normal edge deployment they always exist (annotations migrates them at startup, same DB).
|
||||
|
||||
**Removed from cascade in B7**: `orthophotos`, `gps_corrections`. Those tables are no longer in this DB after B9; if a fielded device still has them they'll just be dropped (cascade no longer touches them).
|
||||
|
||||
## Configuration / External Integrations
|
||||
|
||||
None.
|
||||
|
||||
## Security
|
||||
|
||||
- Behind `[Authorize(Policy = "FL")]` (controller-level).
|
||||
- Manual cascade respects FK direction; no SQL injection surface.
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
1. **Manual cascade vs. database `ON DELETE CASCADE`**: the DB declares plain `REFERENCES` (no `ON DELETE` clause), so the service does the work. Deliberate (likely to permit partial denial / business rules), but it means schema and code are coupled -- any new mission-owned table must be added BOTH to the schema and to this method.
|
||||
2. **No transaction** around the cascade -- failure mid-cascade orphans rows. For PostgreSQL, wrapping in `db.BeginTransactionAsync()` is one extra line and would make the cascade atomic. Opportunistic improvement candidate.
|
||||
3. **`GetMissions` does a count + page query** -- two round trips. Standard. The count is unfiltered by pagination but filtered by the same predicates.
|
||||
4. **`Name` filter and date filter are independent ANDs** -- no support for OR or full-text search.
|
||||
5. **No soft-delete** -- deletes are physical. Audit/recovery would need DB-level WAL or external backup.
|
||||
6. **`UpdateMission` allows changing `VehicleId`** but does NOT validate that any in-mission state (waypoints, etc.) is compatible with the new vehicle. Probably fine if vehicle-specific behavior is downstream (e.g., autopilot recomputes route on next read).
|
||||
@@ -0,0 +1,102 @@
|
||||
# Module: `Azaion.Missions.Services.VehicleService`
|
||||
|
||||
**File**: `Services/VehicleService.cs`
|
||||
|
||||
> **NOTE (forward-looking)**: post-rename. Today's source is `Services/AircraftService.cs` operating over `Aircraft` entities. Renames tracked under Jira AZ-EPIC child B6.
|
||||
|
||||
## Purpose
|
||||
|
||||
Encapsulates vehicle-related domain operations: CRUD plus the "is_default" exclusivity rule. This is the only place the "exactly one vehicle is default" invariant is enforced.
|
||||
|
||||
## Public Interface
|
||||
|
||||
```csharp
|
||||
public class VehicleService(AppDataConnection db) {
|
||||
Task<Vehicle> CreateVehicle(CreateVehicleRequest request);
|
||||
Task<Vehicle> UpdateVehicle(Guid id, UpdateVehicleRequest request);
|
||||
Task<Vehicle> GetVehicle(Guid id);
|
||||
Task<List<Vehicle>> GetVehicles(GetVehiclesQuery query);
|
||||
Task DeleteVehicle(Guid id);
|
||||
Task SetDefault(Guid id, SetDefaultRequest request);
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Logic
|
||||
|
||||
### `CreateVehicle`
|
||||
1. If `request.IsDefault` is `true`, **clear `is_default` on every other vehicle first** (`UPDATE vehicles SET is_default = FALSE WHERE is_default = TRUE`).
|
||||
2. Build a fresh `Vehicle` with `Id = Guid.NewGuid()` and copy every request field 1:1.
|
||||
3. `db.InsertAsync(vehicle)` and return the persisted entity.
|
||||
|
||||
### `UpdateVehicle`
|
||||
1. Load the row by id; throw `KeyNotFoundException` if not found (mapped to 404 by middleware).
|
||||
2. For each property on `UpdateVehicleRequest`, apply only the non-null fields (partial update).
|
||||
3. If `IsDefault.Value == true`, run the same exclusivity clear as `CreateVehicle` BEFORE setting the new default.
|
||||
4. `db.UpdateAsync(vehicle)`.
|
||||
|
||||
### `GetVehicle`
|
||||
- `FirstOrDefaultAsync(v => v.Id == id)` -> 404 on miss.
|
||||
|
||||
### `GetVehicles`
|
||||
- Builds a LINQ query on `db.Vehicles`.
|
||||
- Filters: `query.Name` -> case-insensitive `Contains` (uses `string.ToLower()` on both sides -- runs as `LOWER(name) LIKE %lower(input)%` server-side via LinqToDB translation); `query.IsDefault` -> exact equality.
|
||||
- Orders by `Name` ascending.
|
||||
- Returns the full list -- **no pagination** despite `GetVehiclesQuery` being a query object. Spec accepts this (vehicles are a small dataset).
|
||||
|
||||
### `DeleteVehicle`
|
||||
1. Guard: `db.Missions.AnyAsync(m => m.VehicleId == id)` -> if any mission references the vehicle, throw `InvalidOperationException` ("Vehicle is referenced by missions") -> 409 Conflict.
|
||||
2. Load by id (404 on miss).
|
||||
3. `db.Vehicles.DeleteAsync(v => v.Id == id)`.
|
||||
|
||||
### `SetDefault`
|
||||
- Loads the row (404 on miss).
|
||||
- If `request.IsDefault == true`, clears the flag on every other vehicle, then sets it on the target.
|
||||
- If `request.IsDefault == false`, simply clears the flag on the target -- **no guarantee remains that exactly one vehicle is default**. Behavior is "the user told me to clear the flag, so I clear it".
|
||||
|
||||
## "Exactly one default" rule -- spec vs code
|
||||
|
||||
This is the canonical surviving spec-vs-code divergence in the rename Epic. See Jira **B12** (decision-only ticket).
|
||||
|
||||
- **Spec** (`../../suite/_docs/02_missions.md`): `PATCH /vehicles/{id}/default` "toggles the default flag on a vehicle." Only the target row changes.
|
||||
- **Code**: when setting `IsDefault = true`, the service first clears the flag on **every other vehicle** in the same connection. Two operations, no transaction.
|
||||
|
||||
Two real consequences:
|
||||
1. **Code is stricter than spec** -- in code there can be 0 or 1 default vehicle, never 2+. Spec would allow N defaults if the UI sets multiple.
|
||||
2. **Race window**: two concurrent "set default = true" calls on different rows could both clear-then-set, leaving two defaults. linq2db default behavior is autocommit per statement; no `BeginTransactionAsync` is called.
|
||||
|
||||
B12 either lifts the rule into spec + wraps in a transaction (preferred), or drops the side-effect from code and lets UI handle exclusivity. The race fix is part of either resolution.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `Azaion.Missions.Database.AppDataConnection`
|
||||
- `Azaion.Missions.Database.Entities.Vehicle`
|
||||
- `Azaion.Missions.Database.Entities.Mission` (queried via `db.Missions` in `DeleteVehicle`)
|
||||
- `Azaion.Missions.DTOs` (request/query DTOs)
|
||||
|
||||
## Consumers
|
||||
|
||||
- `Controllers.VehiclesController` -- wraps every method 1:1 with an HTTP route.
|
||||
|
||||
## Data Models
|
||||
|
||||
Reads/writes only `vehicles` table; reads (existence check) `missions` table.
|
||||
|
||||
## Configuration / External Integrations
|
||||
|
||||
None.
|
||||
|
||||
## Security
|
||||
|
||||
- All endpoints in the controller carry `[Authorize(Policy = "FL")]`, so this service is unreachable without a JWT bearing `permissions=FL`.
|
||||
- Exception messages include the supplied id (`"Vehicle {id} not found"`) -- no PII risk for GUIDs.
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
1. **`GetVehicles` ignores pagination** by design -- the dataset is small (a fleet, not a catalog of millions). Inconsistent listing contract with `MissionService.GetMissions` but intentional.
|
||||
2. **"Exactly one default" race** -- see B12 above.
|
||||
3. **Delete fails fast on referenced vehicle** but does NOT cascade-delete or null-out the `vehicle_id` -- strictly a 409 to the caller. Consistent with the schema: `missions.vehicle_id` is `NOT NULL REFERENCES vehicles(id)` with no `ON DELETE` clause (PostgreSQL defaults to `NO ACTION`).
|
||||
4. **Case-insensitive search** uses `string.ToLower()` on both sides, which LinqToDB renders as `LOWER(...)` -- non-indexed; full-table scan on large datasets. Fine at fleet size.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Module: `Azaion.Missions.Services.WaypointService`
|
||||
|
||||
**File**: `Services/WaypointService.cs`
|
||||
|
||||
> **NOTE (forward-looking)**: post-rename + post-GPS-Denied-removal. Today's source still uses `Flight`/`flightId` and the cascade reaches into `gps_corrections`. Renames tracked under Jira AZ-EPIC child B6; cascade shrink under B7.
|
||||
|
||||
## Purpose
|
||||
|
||||
CRUD over waypoints (sub-aggregate of `Mission`) plus a manual cascade-delete chain that walks `waypoint -> media -> annotations -> detection`. All operations are scoped by `missionId` -- no cross-mission waypoint addressing.
|
||||
|
||||
## Public Interface
|
||||
|
||||
```csharp
|
||||
public class WaypointService(AppDataConnection db) {
|
||||
Task<Waypoint> CreateWaypoint(Guid missionId, CreateWaypointRequest request);
|
||||
Task<Waypoint> UpdateWaypoint(Guid missionId, Guid waypointId, UpdateWaypointRequest request);
|
||||
Task<List<Waypoint>> GetWaypoints(Guid missionId);
|
||||
Task DeleteWaypoint(Guid missionId, Guid waypointId);
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Logic
|
||||
|
||||
### `CreateWaypoint`
|
||||
1. Existence check: `db.Missions.AnyAsync(m => m.Id == missionId)` -> `KeyNotFoundException` ("Mission not found") on miss -> 404.
|
||||
2. Build a fresh `Waypoint`:
|
||||
- `Id = Guid.NewGuid()`, `MissionId = missionId`.
|
||||
- `Lat`, `Lon`, `Mgrs` from `request.GeoPoint?` (all three null-passthrough; if `GeoPoint` is null, all three are null).
|
||||
- `WaypointSource`, `WaypointObjective` copied as-is.
|
||||
- `OrderNum`, `Height` copied as-is.
|
||||
3. `db.InsertAsync(waypoint)`.
|
||||
|
||||
### `UpdateWaypoint`
|
||||
1. Load by composite predicate `w.MissionId == missionId && w.Id == waypointId` -> 404 on miss (so updates with the wrong `missionId` get 404, not 403).
|
||||
2. **Full overwrite** -- every field is set unconditionally from the request, including `null`-passthrough on `Lat/Lon/Mgrs`. There is no partial-update semantic here, unlike `UpdateVehicleRequest`.
|
||||
3. `db.UpdateAsync(waypoint)`.
|
||||
|
||||
### `GetWaypoints`
|
||||
- `db.Waypoints.Where(w.MissionId == missionId).OrderBy(w.OrderNum).ToListAsync()` -- no pagination.
|
||||
|
||||
### `DeleteWaypoint` -- manual cascade
|
||||
1. Load by composite (404 on miss).
|
||||
2. `mediaIds = SELECT id FROM media WHERE waypoint_id = waypointId`.
|
||||
3. If any media exist:
|
||||
- `annotationIds = SELECT id FROM annotations WHERE media_id IN (mediaIds)`.
|
||||
- If any annotations exist: `DELETE FROM detection WHERE annotation_id IN (annotationIds)`.
|
||||
- `DELETE FROM annotations WHERE media_id IN (mediaIds)`.
|
||||
4. `DELETE FROM media WHERE waypoint_id = waypointId`.
|
||||
5. `DELETE FROM waypoints WHERE id = waypointId`.
|
||||
|
||||
**Removed from cascade in B7**: `gps_corrections WHERE waypoint_id = waypointId`. Schema gone after B9.
|
||||
|
||||
**No transaction** wraps the cascade either.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `AppDataConnection`, entities `Waypoint`, `Mission` (existence check), and 3 dependent entities used in the cascade.
|
||||
- `DTOs.GeoPoint`, `DTOs.CreateWaypointRequest`, `DTOs.UpdateWaypointRequest`.
|
||||
- `Azaion.Missions.Enums` (`WaypointSource`, `WaypointObjective` -- typed properties).
|
||||
|
||||
## Consumers
|
||||
|
||||
- `Controllers.MissionsController` -- exposed under `missions/{id}/waypoints/*` routes.
|
||||
|
||||
## Data Models
|
||||
|
||||
Reads `missions` (existence). Writes `waypoints`. Cascade also writes `media`, `annotations`, `detection`.
|
||||
|
||||
## Configuration / External Integrations
|
||||
|
||||
None.
|
||||
|
||||
## Security
|
||||
|
||||
- Behind `[Authorize(Policy = "FL")]` via the controller.
|
||||
- Composite-key load (`MissionId AND Id`) means a user cannot operate on a waypoint by guessing only its id -- they must also know the mission id (defense in depth, though both are GUIDs).
|
||||
|
||||
## Tests
|
||||
|
||||
None present.
|
||||
|
||||
## Notes / Smells
|
||||
|
||||
1. **`UpdateWaypoint` does a full overwrite** even though `UpdateWaypointRequest` is "partial-shaped". Any client sending `{}` would silently zero out `Lat/Lon/Mgrs/OrderNum/Height` and reset enums to `0`. Client contract is fragile.
|
||||
2. **`waypoint_id` not found vs. `mission_id`/`waypoint_id` mismatch** both return 404 with the same message -- slight UX issue (you can't tell which id is wrong).
|
||||
3. **No reorder endpoint** -- `OrderNum` is set in the request but there's no atomic "reorder this list" operation. Reordering N waypoints requires N PUTs and is racy.
|
||||
4. **Same transaction gap** as `MissionService.DeleteMission` -- no `BeginTransactionAsync` around the cascade.
|
||||
5. **`GeoPoint?` swallowing** -- sending a request with `GeoPoint = null` clears the location entirely. Likely intentional but combined with "no validation" means an empty waypoint can be created.
|
||||
Reference in New Issue
Block a user