Files
missions/_docs/02_document/modules/service_vehicle.md
T
Oleksandr Bezdieniezhnykh 7025f4d075 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.
2026-05-14 19:48:25 +03:00

5.2 KiB

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

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.