Files
fm_be/internal/transport/http/handlers/flight_data_handler.go
2026-07-16 22:16:45 +07:00

1586 lines
58 KiB
Go

package handlers
import (
"context"
"errors"
"strconv"
"strings"
"time"
mysqldriver "github.com/go-sql-driver/mysql"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
facilitydomain "wucher/internal/domain/facility"
flightdata "wucher/internal/domain/flight_data"
hospitaldomain "wucher/internal/domain/hospital"
icaodomain "wucher/internal/domain/icao"
missiondomain "wucher/internal/domain/mission"
userdomain "wucher/internal/domain/user"
sharedconst "wucher/internal/shared/const"
"wucher/internal/shared/pkg/apperrorsx"
"wucher/internal/shared/pkg/uuidv7"
"wucher/internal/transport/http/dto"
requestdto "wucher/internal/transport/http/dto/request"
responsedto "wucher/internal/transport/http/dto/response"
"wucher/internal/transport/http/jsonapi"
"wucher/internal/transport/http/response"
"wucher/internal/transport/http/validators"
)
type FlightDataHandler struct {
svc flightdata.Service
mission missionService
hospital hospitalService
icao icaoService
user userService
facility facilityService
validate *validators.Validator
}
func NewFlightDataHandler(svc flightdata.Service) *FlightDataHandler {
return &FlightDataHandler{
svc: svc,
validate: validators.New(),
}
}
type hospitalService interface {
GetByID(context.Context, []byte) (*hospitaldomain.Hospital, error)
}
type missionService interface {
GetByID(context.Context, []byte) (*missiondomain.Mission, error)
}
type icaoService interface {
GetByID(context.Context, []byte) (*icaodomain.ICAO, error)
}
type userService interface {
GetByID(context.Context, []byte) (*userdomain.User, error)
}
type facilityService interface {
GetByID(context.Context, []byte) (*facilitydomain.Facility, error)
}
func (h *FlightDataHandler) WithHospitalService(svc hospitalService) *FlightDataHandler {
if h != nil {
h.hospital = svc
}
return h
}
func (h *FlightDataHandler) WithMissionService(svc missionService) *FlightDataHandler {
if h != nil {
h.mission = svc
}
return h
}
func (h *FlightDataHandler) WithICAOService(svc icaoService) *FlightDataHandler {
if h != nil {
h.icao = svc
}
return h
}
func (h *FlightDataHandler) WithUserService(svc userService) *FlightDataHandler {
if h != nil {
h.user = svc
}
return h
}
func (h *FlightDataHandler) WithFacilityService(svc facilityService) *FlightDataHandler {
if h != nil {
h.facility = svc
}
return h
}
func (h *FlightDataHandler) validateFlightDataReferences(ctx context.Context, missionID, coPilotID, fromICAOID, fromHospitalID, toICAOID, toHospitalID []byte) error {
if len(missionID) == 16 && h != nil && h.mission != nil {
row, err := h.mission.GetByID(ctx, missionID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return flightdata.ErrMissionReference
}
return err
}
if row == nil {
return flightdata.ErrMissionReference
}
}
if len(coPilotID) == 16 && h != nil && h.user != nil {
row, err := h.user.GetByID(ctx, coPilotID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return flightdata.ErrCoPilotReference
}
return err
}
if row == nil {
return flightdata.ErrCoPilotReference
}
}
if len(fromICAOID) == 16 && h != nil && h.icao != nil {
row, err := h.icao.GetByID(ctx, fromICAOID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return flightdata.ErrOriginLocationReference
}
return err
}
if row == nil {
return flightdata.ErrOriginLocationReference
}
}
if len(toICAOID) == 16 && h != nil && h.icao != nil {
row, err := h.icao.GetByID(ctx, toICAOID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return flightdata.ErrDestinationLocationReference
}
return err
}
if row == nil {
return flightdata.ErrDestinationLocationReference
}
}
if len(fromHospitalID) == 16 && h != nil && h.hospital != nil {
row, err := h.hospital.GetByID(ctx, fromHospitalID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return flightdata.ErrOriginLocationReference
}
return err
}
if row == nil {
return flightdata.ErrOriginLocationReference
}
}
if len(toHospitalID) == 16 && h != nil && h.hospital != nil {
row, err := h.hospital.GetByID(ctx, toHospitalID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return flightdata.ErrDestinationLocationReference
}
return err
}
if row == nil {
return flightdata.ErrDestinationLocationReference
}
}
return nil
}
func (h *FlightDataHandler) validateSPOFacilityReferences(ctx context.Context, attrs requestdto.FlightDataCreateAttributes) *jsonapi.ErrorObject {
if h == nil || h.facility == nil {
return nil
}
check := func(pointer, raw, expectedCategory string) *jsonapi.ErrorObject {
id, err := uuidv7.ParseString(strings.TrimSpace(raw))
if err != nil || len(id) != 16 {
return nil
}
row, err := h.facility.GetByID(ctx, id)
if err != nil || row == nil || !strings.EqualFold(strings.TrimSpace(row.Category), expectedCategory) {
return &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageFacilityReference,
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
return nil
}
if attrs.HESLO != nil {
for i, f := range attrs.HESLO.Flights {
if errObj := check("/data/attributes/heslo/flights/"+strconv.Itoa(i)+"/heslo_rope_label_id", f.HESLORopeLabelID, sharedconst.FlightDataFacilityCategoryHesloRopeLabel); errObj != nil {
return errObj
}
for j, s := range f.Slings {
if errObj := check("/data/attributes/heslo/flights/"+strconv.Itoa(i)+"/slings/"+strconv.Itoa(j)+"/sling_id", s.SlingID, sharedconst.FlightDataFacilityCategoryHesloRopeLength); errObj != nil {
return errObj
}
}
}
}
if attrs.Logging != nil {
for i, s := range attrs.Logging.Slings {
if errObj := check("/data/attributes/logging/slings/"+strconv.Itoa(i)+"/sling_id", s.SlingID, sharedconst.FlightDataFacilityCategoryHesloHook); errObj != nil {
return errObj
}
}
}
if attrs.HEC != nil {
for i, f := range attrs.HEC.Flights {
if errObj := check("/data/attributes/hec/flights/"+strconv.Itoa(i)+"/equipment_id", f.EquipmentID, sharedconst.FlightDataFacilityCategoryHecRopeLabel); errObj != nil {
return errObj
}
}
for i, s := range attrs.HEC.Slings {
if errObj := check("/data/attributes/hec/slings/"+strconv.Itoa(i)+"/sling_id", s.SlingID, sharedconst.FlightDataFacilityCategoryHecRopeLength); errObj != nil {
return errObj
}
}
}
return nil
}
// ListFlightData godoc
// @Summary List flight data
// @Description List flight data rows. Supports `date` for exact flight take-off date filtering (`YYYY-MM-DD`), plus `mission_id` and `flight_data_id` filters.
// @Description SPO-specific response fields (`heslo`, `logging`, and `hec`) are returned only when the related mission type is `SPO`.
// @Tags Mission - Flight Data
// @Produce json
// @Param date query string false "Filter by flight take-off date (YYYY-MM-DD)"
// @Param mission_id query string false "Filter by mission UUID"
// @Param flight_data_id query string false "Filter by flight-data UUID"
// @Param page[number] query int false "Page number"
// @Param page[size] query int false "Page size"
// @Param page query int false "Page number (fallback)"
// @Param size query int false "Page size (fallback)"
// @Param limit query int false "Page size override"
// @Success 200 {object} responsedto.FlightDataListResponse
// @Failure 422 {object} jsonapi.ErrorDocument
// @Failure 400 {object} jsonapi.ErrorDocument
// @Router /api/v1/flight-data/get-all [get]
func (h *FlightDataHandler) List(c *fiber.Ctx) error {
dateFilter := strings.TrimSpace(c.Query("date"))
if dateFilter != "" {
if _, err := time.Parse("2006-01-02", dateFilter); err != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "date must be YYYY-MM-DD",
Source: &jsonapi.ErrorSource{Pointer: "/date"},
}})
}
}
missionQuery := c.Query("mission_id")
missionID, errObj := parseOptionalUUIDFlightData(&missionQuery, "/query/mission_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
flightDataQuery := c.Query("flight_data_id")
flightDataID, errObj := parseOptionalUUIDFlightData(&flightDataQuery, "/query/flight_data_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
pageNumber := parseIntDefault(c.Query("page[number]"), 0)
if pageNumber <= 0 {
pageNumber = parseIntDefault(c.Query("page"), 1)
}
pageSize := parseIntDefault(c.Query("page[size]"), 0)
if pageSize <= 0 {
pageSize = parseIntDefault(c.Query("limit"), 0)
}
if pageSize <= 0 {
pageSize = parseIntDefault(c.Query("size"), 20)
}
if pageSize > 100 {
pageSize = 100
}
if pageSize < 1 {
pageSize = 20
}
if pageNumber < 1 {
pageNumber = 1
}
offset := (pageNumber - 1) * pageSize
_ = offset
rows, total, err := h.svc.List(c.UserContext(), dateFilter, missionID, flightDataID, 0, 0)
if err != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "List failed",
Detail: safeInternalDetail(),
}})
}
data := make([]responsedto.FlightDataResource, 0, len(rows))
for i := range rows {
resource, buildErr := h.buildFlightDataResource(c, &rows[i])
if buildErr != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "List failed",
Detail: buildErr.Error(),
}})
}
data = append(data, resource)
}
return response.WriteWithMeta(c, fiber.StatusOK, data, map[string]any{
"total": total,
})
}
// ListFlightDataDatatable godoc
// @Summary List flight data (datatable)
// @Description Datatable-friendly list of flight data rows. Supports `date` for exact flight take-off date filtering (`YYYY-MM-DD`), plus `mission_id` and `flight_data_id` filters.
// @Description Same payload shape as `get-all`, but with DataTables paging params (`start`, `length`, `draw`).
// @Description SPO-specific response fields (`heslo`, `logging`, and `hec`) are returned only when the related mission type is `SPO`.
// @Tags Mission - Flight Data
// @Produce json
// @Param draw query int false "DataTables draw counter"
// @Param start query int false "Paging start offset"
// @Param length query int false "Paging length"
// @Param page[number] query int false "Page number"
// @Param page[size] query int false "Page size"
// @Param page query int false "Page number (fallback)"
// @Param size query int false "Page size (fallback)"
// @Param limit query int false "Page size override"
// @Param date query string false "Filter by flight take-off date (YYYY-MM-DD)"
// @Param mission_id query string false "Filter by mission UUID"
// @Param flight_data_id query string false "Filter by flight-data UUID"
// @Success 200 {object} responsedto.FlightDataListResponse
// @Failure 422 {object} jsonapi.ErrorDocument
// @Failure 400 {object} jsonapi.ErrorDocument
// @Router /api/v1/flight-data/get-all/dt [get]
func (h *FlightDataHandler) ListDatatable(c *fiber.Ctx) error {
dateFilter := strings.TrimSpace(c.Query("date"))
if dateFilter != "" {
if _, err := time.Parse("2006-01-02", dateFilter); err != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "date must be YYYY-MM-DD",
Source: &jsonapi.ErrorSource{Pointer: "/date"},
}})
}
}
missionQuery := c.Query("mission_id")
missionID, errObj := parseOptionalUUIDFlightData(&missionQuery, "/query/mission_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
flightDataQuery := c.Query("flight_data_id")
flightDataID, errObj := parseOptionalUUIDFlightData(&flightDataQuery, "/query/flight_data_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
pageNumber := parseIntDefault(c.Query("page[number]"), 0)
if pageNumber <= 0 {
pageNumber = parseIntDefault(c.Query("page"), 1)
}
pageSize := parseIntDefault(c.Query("page[size]"), 0)
if pageSize <= 0 {
pageSize = parseIntDefault(c.Query("limit"), 0)
}
if pageSize <= 0 {
pageSize = parseIntDefault(c.Query("size"), 20)
}
if pageSize > 100 {
pageSize = 100
}
if pageSize < 1 {
pageSize = 20
}
if pageNumber < 1 {
pageNumber = 1
}
if start := parseIntDefault(c.Query("start"), -1); start >= 0 && c.Query("length") != "" {
pageSize = parseIntDefault(c.Query("length"), pageSize)
if pageSize < 1 {
pageSize = 20
}
pageNumber = (start / pageSize) + 1
}
offset := (pageNumber - 1) * pageSize
rows, total, err := h.svc.List(c.UserContext(), dateFilter, missionID, flightDataID, pageSize, offset)
if err != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "List failed",
Detail: safeInternalDetail(),
}})
}
data := make([]responsedto.FlightDataResource, 0, len(rows))
for i := range rows {
resource, buildErr := h.buildFlightDataResource(c, &rows[i])
if buildErr != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "List failed",
Detail: buildErr.Error(),
}})
}
data = append(data, resource)
}
return response.WriteWithMeta(c, fiber.StatusOK, data, map[string]any{
"page_number": pageNumber,
"page_size": pageSize,
"total": total,
})
}
// CreateFlightData godoc
// @Summary Create flight data
// @Description Create a flight data row.
// @Description Required field: `mission_id`. `co_pilot_id` is optional.
// @Description Swagger request example shows the complete common payload for an `SPO` mission. `SPO` is the default example shape, and for other mission types (`CAT` and `NCO`) you do not need to send the `hec`, `heslo`, or `logging` blocks. `rotor_brake_cycle` is optional for `SPO`, `CAT`, and `NCO`.
// @Description SPO facility references must point to facilities with categories: `heslo_rope_label_id -> heslo-rope-label`, `heslo.slings -> heslo-rope-length`, `logging.slings -> heslo-hook`, `hec.flights -> hec-rope-label`, and `hec.slings -> hec-rope-length`.
// @Description SPO-specific response fields (`heslo`, `logging`, and `hec`) are returned only when the related mission type is `SPO`.
// @Description Example request: {"data":{"type":"flight_data_create","attributes":{"mission_id":"019d6774-98e8-7fba-9ef4-3795c0da8a13"}}}
// @Tags Mission - Flight Data
// @Accept json
// @Produce json
// @Param request body requestdto.FlightDataCreateRequest true "JSON:API flight data create request. Example is for SPO; CAT/NCO can omit SPO-only blocks and other optional fields." SchemaExample({"data":{"type":"flight_data_create","attributes":{"mission_id":"019d6774-98e8-7fba-9ef4-3795c0da8a13","flight_take_off":"2026-04-20T08:00:00Z","flight_landing":"2026-04-20T09:15:00Z","from_icao_id":"019d6774-98e8-7fba-9ef4-3795c0da8a13","to_icao_id":"019d6774-98e8-7fba-9ef4-3795c0da8a13","hook_releases":0,"ticket_no":"SPO-001","engine":"AIRBUS H145","delivery_note_number":"DN-2026-0001","customer_name":"Patient Transfer","other_information":"SPO mission sample","landing_count":1,"heslo":{"flights":[{"id":"string","rot":0,"heslo_rope_label_id":"string","heslo_rope_label_name":"string"}],"slings":[{"id":"string","heslo_flight_id":"string","sling_id":"string","sling_name":"string"}]},"logging":{"slings":[{"id":"string","sling_id":"string","sling_name":"string"}]},"hec":{"flights":[{"equipment_id":"string","equipment_name":"string","hec_cycle":0,"id":"string","rot":0}],"loads":[{"id":"string","load_category":"string","quantity":0}],"slings":[{"id":"string","sling_id":"string","sling_name":"string"}]}}}})
// @Success 201 {object} responsedto.FlightDataResponse
// @Failure 422 {object} jsonapi.ErrorDocument
// @Failure 400 {object} jsonapi.ErrorDocument
// @Router /api/v1/flight-data/create [post]
func (h *FlightDataHandler) Create(c *fiber.Ctx) error {
var req requestdto.FlightDataCreateRequest
if err := c.BodyParser(&req); err != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: safeInternalDetail(),
}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, errs)
}
attrs := req.Data.Attributes
missionID, errObj := parseRequiredUUID(attrs.MissionID, "/data/attributes/mission_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
coPilotID, errObj := parseOptionalUUIDFlightData(attrs.CoPilotID, "/data/attributes/co_pilot_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
fromICAOID, errObj := parseOptionalUUIDFlightData(attrs.FromICAOID, "/data/attributes/from_icao_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
fromHospitalID, errObj := parseOptionalUUIDFlightData(attrs.FromHospitalID, "/data/attributes/from_hospital_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
toICAOID, errObj := parseOptionalUUIDFlightData(attrs.ToICAOID, "/data/attributes/to_icao_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
toHospitalID, errObj := parseOptionalUUIDFlightData(attrs.ToHospitalID, "/data/attributes/to_hospital_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
if err := h.validateFlightDataReferences(c.UserContext(), missionID, coPilotID, fromICAOID, fromHospitalID, toICAOID, toHospitalID); err != nil {
return writeFlightDataServiceError(c, err, missionID, coPilotID, fromICAOID, fromHospitalID, toICAOID, toHospitalID)
}
row := &flightdata.FlightData{
MissionID: missionID,
CoPilotID: coPilotID,
FromICAOID: fromICAOID,
FromHospitalID: fromHospitalID,
ToICAOID: toICAOID,
ToHospitalID: toHospitalID,
CreatedBy: actorUserID(c),
UpdatedBy: actorUserID(c),
TicketNo: stringPtrOrEmpty(attrs.TicketNo),
Engine: stringPtrOrEmpty(attrs.Engine),
DeliveryNoteNumber: stringPtrOrEmpty(attrs.DeliveryNoteNumber),
CustomerName: stringPtrOrEmpty(attrs.CustomerName),
OtherInformation: stringPtrOrEmpty(attrs.OtherInformation),
}
if attrs.FlightTakeOff != nil {
v, err := parseRFC3339(*attrs.FlightTakeOff, "/data/attributes/flight_take_off")
if err != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*err})
}
row.FlightTakeOff = v
}
if attrs.FlightLanding != nil {
v, err := parseRFC3339(*attrs.FlightLanding, "/data/attributes/flight_landing")
if err != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*err})
}
row.FlightLanding = v
}
if attrs.FlightDurationSecs != nil {
row.FlightDuration = time.Duration(*attrs.FlightDurationSecs) * time.Second
}
if attrs.FlightREDSecs != nil {
row.FlightRED = time.Duration(*attrs.FlightREDSecs) * time.Second
}
if attrs.MaxN1 != nil {
row.MaxN1 = *attrs.MaxN1
}
if attrs.MaxN2 != nil {
row.MaxN2 = *attrs.MaxN2
}
if attrs.PaxCount != nil {
row.PaxCount = *attrs.PaxCount
}
if attrs.LandingCount != nil {
row.LandingCount = *attrs.LandingCount
}
if attrs.RotorBrakeCycle != nil {
row.RotorBrakeCycle = *attrs.RotorBrakeCycle
}
if attrs.HookReleases != nil {
row.HookReleases = *attrs.HookReleases
}
if attrs.FlightPlanDistance != nil {
row.FlightPlanDistance = *attrs.FlightPlanDistance
}
if attrs.FlightPlanTimeSecs != nil {
row.FlightPlanTime = time.Duration(*attrs.FlightPlanTimeSecs) * time.Second
}
if attrs.FlightPlanTrueCourse != nil {
row.FlightPlanTrueCourse = *attrs.FlightPlanTrueCourse
}
if attrs.FuelBeforeFlight != nil {
row.FuelBeforeFlight = *attrs.FuelBeforeFlight
}
if attrs.FuelUpload != nil {
row.FuelUpload = *attrs.FuelUpload
}
if attrs.FuelAfterFlight != nil {
row.FuelAfterFlight = *attrs.FuelAfterFlight
}
if attrs.FuelPlanning != nil {
row.FuelPlanning = *attrs.FuelPlanning
}
if attrs.FlightPositioning != nil {
row.FlightPositioning = *attrs.FlightPositioning
}
if err := h.svc.Create(c.UserContext(), row); err != nil {
return writeFlightDataServiceError(c, err, row.MissionID, row.CoPilotID, row.FromICAOID, row.FromHospitalID, row.ToICAOID, row.ToHospitalID)
}
if spoData := parseSPOUpsertData(attrs); spoData != nil {
if errObj := h.validateSPOFacilityReferences(c.UserContext(), attrs); errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
if err := h.svc.UpsertSPODetails(c.UserContext(), row.ID, spoData); err != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: safeInternalDetail(),
}})
}
}
created, err := h.svc.GetByID(c.UserContext(), row.ID)
if err == nil && created != nil {
resource, buildErr := h.buildFlightDataResource(c, created)
if buildErr != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: buildErr.Error(),
}})
}
return response.Write(c, fiber.StatusCreated, resource)
}
if rowByMission, lookupErr := h.svc.GetByMissionID(c.UserContext(), row.MissionID); lookupErr == nil && rowByMission != nil {
resource, buildErr := h.buildFlightDataResource(c, rowByMission)
if buildErr != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: buildErr.Error(),
}})
}
return response.Write(c, fiber.StatusCreated, resource)
}
resource, buildErr := h.buildFlightDataResource(c, row)
if buildErr != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: buildErr.Error(),
}})
}
return response.Write(c, fiber.StatusCreated, resource)
}
// GetFlightData godoc
// @Summary Get flight data by ID
// @Description Get flight data detail by ID. Path `id` can be mission UUID, flight-data UUID, or flight UUID.
// @Description SPO-specific response fields (`heslo`, `logging`, and `hec`) are returned only when the related mission type is `SPO`.
// @Tags Mission - Flight Data
// @Produce json
// @Param id path string true "Mission UUID / Flight Data UUID / Flight UUID (UUIDv7)"
// @Success 200 {object} responsedto.FlightDataResponse
// @Failure 422 {object} jsonapi.ErrorDocument
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 400 {object} jsonapi.ErrorDocument
// @Router /api/v1/flight-data/get/{id} [get]
func (h *FlightDataHandler) Get(c *fiber.Ctx) error {
rawID := strings.TrimSpace(c.Params("id"))
parsedID, err := uuidv7.ParseString(rawID)
if err != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageInvalidPathUUID,
Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
}})
}
// Primary flow: mission UUID -> flight_data by mission relation.
row, err := h.svc.GetByMissionID(c.UserContext(), parsedID)
if err != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Get failed",
Detail: safeInternalDetail(),
}})
}
if row == nil {
// Backward compatibility for older consumers.
row, err = h.svc.GetByID(c.UserContext(), parsedID)
}
if err != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Get failed",
Detail: safeInternalDetail(),
}})
}
if row == nil {
row, err = h.svc.GetByFlightID(c.UserContext(), parsedID)
}
if err != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Get failed",
Detail: safeInternalDetail(),
}})
}
if row == nil {
return writeFlightDataErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: flightdata.ErrorMessageNotFound,
}})
}
resource, buildErr := h.buildFlightDataResource(c, row)
if buildErr != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Get failed",
Detail: buildErr.Error(),
}})
}
return response.Write(c, fiber.StatusOK, resource)
}
// UpdateFlightData godoc
// @Summary Update flight data by ID
// @Description Update a flight data row by UUID.
// @Description Required field: `mission_id`. `co_pilot_id` is optional.
// @Description Swagger request example uses the same SPO-shaped payload as create, but this endpoint updates an existing flight-data row.
// @Description SPO facility references must point to facilities with categories: `heslo_rope_label_id -> heslo-rope-label`, `heslo.slings -> heslo-rope-length`, `logging.slings -> heslo-hook`, `hec.flights -> hec-rope-label`, and `hec.slings -> hec-rope-length`.
// @Description For frontend usage, send only the fields that need to change; omitted fields stay unchanged. `SPO` is the default example shape, and for `CAT`/`NCO` you do not need to send the `hec`, `heslo`, or `logging` blocks. `rotor_brake_cycle` is optional for `SPO`, `CAT`, and `NCO`.
// @Description SPO-specific response fields (`heslo`, `logging`, and `hec`) are returned only when the related mission type is `SPO`.
// @Description Example request: {"data":{"type":"flight_data_create","attributes":{"mission_id":"019d6774-98e8-7fba-9ef4-3795c0da8a13"}}}
// @Tags Mission - Flight Data
// @Accept json
// @Produce json
// @Param id path string true "Flight Data UUID (UUIDv7)"
// @Param request body requestdto.FlightDataUpdateRequest true "JSON:API flight data update request. Send only fields you want to change; omit the rest." SchemaExample({"data":{"type":"flight_data_update","attributes":{"mission_id":"019d6774-98e8-7fba-9ef4-3795c0da8a13","flight_take_off":"2026-04-20T08:00:00Z","flight_landing":"2026-04-20T09:15:00Z","from_icao_id":"019d6774-98e8-7fba-9ef4-3795c0da8a13","to_icao_id":"019d6774-98e8-7fba-9ef4-3795c0da8a13","hook_releases":0,"ticket_no":"SPO-001","engine":"AIRBUS H145","delivery_note_number":"DN-2026-0001","customer_name":"Patient Transfer","other_information":"SPO mission sample","landing_count":1,"heslo":{"flights":[{"id":"string","rot":0,"heslo_rope_label_id":"string","heslo_rope_label_name":"string"}],"slings":[{"id":"string","heslo_flight_id":"string","sling_id":"string","sling_name":"string"}]},"logging":{"slings":[{"id":"string","sling_id":"string","sling_name":"string"}]},"hec":{"flights":[{"equipment_id":"string","equipment_name":"string","hec_cycle":0,"id":"string","rot":0}],"loads":[{"id":"string","load_category":"string","quantity":0}],"slings":[{"id":"string","sling_id":"string","sling_name":"string"}]}}}})
// @Success 200 {object} responsedto.FlightDataResponse
// @Failure 422 {object} jsonapi.ErrorDocument
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 400 {object} jsonapi.ErrorDocument
// @Router /api/v1/flight-data/update/{id} [patch]
func (h *FlightDataHandler) Update(c *fiber.Ctx) error {
rawID := strings.TrimSpace(c.Params("id"))
id, err := uuidv7.ParseString(rawID)
if err != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageInvalidPathUUID,
Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
}})
}
var req requestdto.FlightDataUpdateRequest
if err := c.BodyParser(&req); err != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: safeInternalDetail(),
}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, errs)
}
attrs := req.Data.Attributes
missionID, errObj := parseRequiredUUID(attrs.MissionID, "/data/attributes/mission_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
coPilotID, errObj := parseOptionalUUIDFlightData(attrs.CoPilotID, "/data/attributes/co_pilot_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
fromICAOID, errObj := parseOptionalUUIDFlightData(attrs.FromICAOID, "/data/attributes/from_icao_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
fromHospitalID, errObj := parseOptionalUUIDFlightData(attrs.FromHospitalID, "/data/attributes/from_hospital_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
toICAOID, errObj := parseOptionalUUIDFlightData(attrs.ToICAOID, "/data/attributes/to_icao_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
toHospitalID, errObj := parseOptionalUUIDFlightData(attrs.ToHospitalID, "/data/attributes/to_hospital_id")
if errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
if err := h.validateFlightDataReferences(c.UserContext(), missionID, coPilotID, fromICAOID, fromHospitalID, toICAOID, toHospitalID); err != nil {
return writeFlightDataServiceError(c, err, missionID, coPilotID, fromICAOID, fromHospitalID, toICAOID, toHospitalID)
}
row, err := h.svc.GetByID(c.UserContext(), id)
if err != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Get failed",
Detail: safeInternalDetail(),
}})
}
if row == nil {
return writeFlightDataErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: flightdata.ErrorMessageNotFound,
}})
}
row.MissionID = missionID
if len(coPilotID) > 0 {
row.CoPilotID = coPilotID
}
row.FromICAOID = fromICAOID
row.FromHospitalID = fromHospitalID
row.ToICAOID = toICAOID
row.ToHospitalID = toHospitalID
row.UpdatedBy = actorUserID(c)
if attrs.TicketNo != nil {
row.TicketNo = stringPtrOrEmpty(attrs.TicketNo)
}
if attrs.Engine != nil {
row.Engine = stringPtrOrEmpty(attrs.Engine)
}
if attrs.DeliveryNoteNumber != nil {
row.DeliveryNoteNumber = stringPtrOrEmpty(attrs.DeliveryNoteNumber)
}
if attrs.CustomerName != nil {
row.CustomerName = stringPtrOrEmpty(attrs.CustomerName)
}
if attrs.OtherInformation != nil {
row.OtherInformation = stringPtrOrEmpty(attrs.OtherInformation)
}
if attrs.FlightTakeOff != nil {
v, err := parseRFC3339(*attrs.FlightTakeOff, "/data/attributes/flight_take_off")
if err != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*err})
}
row.FlightTakeOff = v
}
if attrs.FlightLanding != nil {
v, err := parseRFC3339(*attrs.FlightLanding, "/data/attributes/flight_landing")
if err != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*err})
}
row.FlightLanding = v
}
if attrs.FlightDurationSecs != nil {
row.FlightDuration = time.Duration(*attrs.FlightDurationSecs) * time.Second
}
if attrs.FlightREDSecs != nil {
row.FlightRED = time.Duration(*attrs.FlightREDSecs) * time.Second
}
if attrs.MaxN1 != nil {
row.MaxN1 = *attrs.MaxN1
}
if attrs.MaxN2 != nil {
row.MaxN2 = *attrs.MaxN2
}
if attrs.PaxCount != nil {
row.PaxCount = *attrs.PaxCount
}
if attrs.LandingCount != nil {
row.LandingCount = *attrs.LandingCount
}
if attrs.RotorBrakeCycle != nil {
row.RotorBrakeCycle = *attrs.RotorBrakeCycle
}
if attrs.HookReleases != nil {
row.HookReleases = *attrs.HookReleases
}
if attrs.FlightPlanDistance != nil {
row.FlightPlanDistance = *attrs.FlightPlanDistance
}
if attrs.FlightPlanTimeSecs != nil {
row.FlightPlanTime = time.Duration(*attrs.FlightPlanTimeSecs) * time.Second
}
if attrs.FlightPlanTrueCourse != nil {
row.FlightPlanTrueCourse = *attrs.FlightPlanTrueCourse
}
if attrs.FuelBeforeFlight != nil {
row.FuelBeforeFlight = *attrs.FuelBeforeFlight
}
if attrs.FuelUpload != nil {
row.FuelUpload = *attrs.FuelUpload
}
if attrs.FuelAfterFlight != nil {
row.FuelAfterFlight = *attrs.FuelAfterFlight
}
if attrs.FuelPlanning != nil {
row.FuelPlanning = *attrs.FuelPlanning
}
if attrs.FlightPositioning != nil {
row.FlightPositioning = *attrs.FlightPositioning
}
if err := h.svc.Update(c.UserContext(), row); err != nil {
return writeFlightDataServiceError(c, err, row.MissionID, row.CoPilotID, row.FromICAOID, row.FromHospitalID, row.ToICAOID, row.ToHospitalID)
}
if spoData := parseSPOUpsertData(attrs); spoData != nil {
if errObj := h.validateSPOFacilityReferences(c.UserContext(), attrs); errObj != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
if err := h.svc.UpsertSPODetails(c.UserContext(), row.ID, spoData); err != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Update failed",
Detail: safeInternalDetail(),
}})
}
}
updated, err := h.svc.GetByID(c.UserContext(), row.ID)
if err == nil && updated != nil {
resource, buildErr := h.buildFlightDataResource(c, updated)
if buildErr != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Update failed",
Detail: buildErr.Error(),
}})
}
return response.Write(c, fiber.StatusOK, resource)
}
resource, buildErr := h.buildFlightDataResource(c, row)
if buildErr != nil {
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Update failed",
Detail: buildErr.Error(),
}})
}
return response.Write(c, fiber.StatusOK, resource)
}
// DeleteFlightData godoc
// @Summary Delete flight data by ID
// @Description Soft delete flight data by UUID.
// @Tags Mission - Flight Data
// @Produce json
// @Param id path string true "Flight Data UUID (UUIDv7)"
// @Success 200 {object} dto.GenericDeleteResponse
// @Failure 422 {object} jsonapi.ErrorDocument
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 400 {object} jsonapi.ErrorDocument
// @Router /api/v1/flight-data/delete/{id} [delete]
func (h *FlightDataHandler) Delete(c *fiber.Ctx) error {
rawID := strings.TrimSpace(c.Params("id"))
id, err := uuidv7.ParseString(rawID)
if err != nil {
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageInvalidPathUUID,
Source: &jsonapi.ErrorSource{Pointer: "/path/id"},
}})
}
if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return writeFlightDataErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: "flight data not found",
}})
}
return writeFlightDataServiceError(c, err)
}
return response.Write(c, fiber.StatusOK, dto.NewGenericDeleteResponse("flight_data_delete"))
}
func flightDataResource(row *flightdata.FlightData) responsedto.FlightDataResource {
resource := responsedto.FlightDataResource{
Type: sharedconst.FlightDataResourceType,
ID: idString(row.ID),
Attributes: buildBaseFlightDataAttributes(row),
}
return resource
}
func (h *FlightDataHandler) buildFlightDataResource(c *fiber.Ctx, row *flightdata.FlightData) (responsedto.FlightDataResource, error) {
return buildFlightDataResourceWithService(c.UserContext(), h.svc, row)
}
func (h *FlightDataHandler) buildSPODetails(c *fiber.Ctx, row *flightdata.FlightData) (
*responsedto.FlightDataSPOHESLODetails,
*responsedto.FlightDataSPOLoggingDetails,
*responsedto.FlightDataSPOHECDetails,
error,
) {
return buildFlightDataSPODetails(c.UserContext(), h.svc, row)
}
func missionRefFlightData(row *flightdata.FlightData) *responsedto.FlightDataMissionRef {
if row == nil {
return nil
}
if row.Mission == nil && len(row.MissionID) == 0 {
return nil
}
out := &responsedto.FlightDataMissionRef{
ID: idString(row.MissionID),
}
if row.Mission != nil {
out.ID = idString(row.Mission.ID)
out.Type = strings.TrimSpace(row.Mission.Type)
}
return out
}
func coPilotRefFlightData(row *flightdata.FlightData) *responsedto.FlightDataCoPilotRef {
if row == nil {
return nil
}
if row.CoPilot == nil && len(row.CoPilotID) == 0 {
return nil
}
out := &responsedto.FlightDataCoPilotRef{
ID: idString(row.CoPilotID),
}
if row.CoPilot != nil {
out.ID = idString(row.CoPilot.ID)
out.FirstName = strings.TrimSpace(row.CoPilot.FirstName)
out.LastName = strings.TrimSpace(row.CoPilot.LastName)
out.Email = strings.TrimSpace(row.CoPilot.Email)
}
return out
}
func fromLocationRefFlightData(row *flightdata.FlightData) *responsedto.FlightDataLocationRef {
if row == nil {
return nil
}
if row.FromICAO != nil {
code := strings.TrimSpace(row.FromICAO.ICAOCode)
return &responsedto.FlightDataLocationRef{
Type: sharedconst.FlightDataLocationTypeICAO,
ID: idString(row.FromICAO.ID),
Code: code,
Label: code,
}
}
if row.FromHospital != nil {
name := strings.TrimSpace(row.FromHospital.Name)
return &responsedto.FlightDataLocationRef{
Type: sharedconst.FlightDataLocationTypeHospital,
ID: idString(row.FromHospital.ID),
Name: name,
Label: name,
}
}
if len(row.FromICAOID) > 0 {
return &responsedto.FlightDataLocationRef{Type: sharedconst.FlightDataLocationTypeICAO, ID: idString(row.FromICAOID)}
}
if len(row.FromHospitalID) > 0 {
return &responsedto.FlightDataLocationRef{Type: sharedconst.FlightDataLocationTypeHospital, ID: idString(row.FromHospitalID)}
}
return nil
}
func toLocationRefFlightData(row *flightdata.FlightData) *responsedto.FlightDataLocationRef {
if row == nil {
return nil
}
if row.ToICAO != nil {
code := strings.TrimSpace(row.ToICAO.ICAOCode)
return &responsedto.FlightDataLocationRef{
Type: sharedconst.FlightDataLocationTypeICAO,
ID: idString(row.ToICAO.ID),
Code: code,
Label: code,
}
}
if row.ToHospital != nil {
name := strings.TrimSpace(row.ToHospital.Name)
return &responsedto.FlightDataLocationRef{
Type: sharedconst.FlightDataLocationTypeHospital,
ID: idString(row.ToHospital.ID),
Name: name,
Label: name,
}
}
if len(row.ToICAOID) > 0 {
return &responsedto.FlightDataLocationRef{Type: sharedconst.FlightDataLocationTypeICAO, ID: idString(row.ToICAOID)}
}
if len(row.ToHospitalID) > 0 {
return &responsedto.FlightDataLocationRef{Type: sharedconst.FlightDataLocationTypeHospital, ID: idString(row.ToHospitalID)}
}
return nil
}
func parseRequiredUUID(raw, pointer string) ([]byte, *jsonapi.ErrorObject) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageFieldRequired,
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
v, err := uuidv7.ParseString(trimmed)
if err != nil {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageInvalidUUID,
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
return v, nil
}
func parseOptionalUUIDFlightData(raw *string, pointer string) ([]byte, *jsonapi.ErrorObject) {
if raw == nil {
return nil, nil
}
trimmed := strings.TrimSpace(*raw)
if trimmed == "" {
return nil, nil
}
v, err := uuidv7.ParseString(trimmed)
if err != nil {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageInvalidUUID,
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
return v, nil
}
func parseRFC3339(raw, pointer string) (time.Time, *jsonapi.ErrorObject) {
v, err := time.Parse(time.RFC3339, strings.TrimSpace(raw))
if err != nil {
return time.Time{}, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageInvalidRFC3339,
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
return v.UTC(), nil
}
func writeFlightDataServiceError(c *fiber.Ctx, err error, refs ...[]byte) error {
var (
fromICAOID []byte
fromHospitalID []byte
toICAOID []byte
toHospitalID []byte
)
if len(refs) > 2 {
fromICAOID = refs[2]
}
if len(refs) > 3 {
fromHospitalID = refs[3]
}
if len(refs) > 4 {
toICAOID = refs[4]
}
if len(refs) > 5 {
toHospitalID = refs[5]
}
switch {
case errors.Is(err, flightdata.ErrMissionReference):
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageMissionReference,
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/mission_id"},
}})
case errors.Is(err, flightdata.ErrOriginLocationReference):
pointer := "/data/attributes/from_icao_id"
if len(fromICAOID) == 0 && len(fromHospitalID) > 0 {
pointer = "/data/attributes/from_hospital_id"
}
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageOriginLocationReference,
Source: &jsonapi.ErrorSource{Pointer: pointer},
}})
case errors.Is(err, flightdata.ErrDestinationLocationReference):
pointer := "/data/attributes/to_icao_id"
if len(toICAOID) == 0 && len(toHospitalID) > 0 {
pointer = "/data/attributes/to_hospital_id"
}
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageDestinationLocationReference,
Source: &jsonapi.ErrorSource{Pointer: pointer},
}})
case errors.Is(err, flightdata.ErrCoPilotReference):
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: flightdata.ErrorMessageCoPilotReference,
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/co_pilot_id"},
}})
case errors.Is(err, flightdata.ErrCreateBlocked):
return writeFlightDataErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
Status: "409",
Title: "Conflict",
Detail: flightdata.ErrorMessageCreateBlocked,
}})
case errors.Is(err, flightdata.ErrLocked):
return writeFlightDataErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{
Status: "423",
Title: "Locked",
Detail: flightdata.ErrorMessageLocked,
}})
case errors.Is(err, flightdata.ErrLockedAfterFlightInspection):
return writeFlightDataErrors(c, fiber.StatusLocked, []jsonapi.ErrorObject{{
Status: "423",
Title: "Locked",
Detail: flightdata.ErrorMessageLockedAfterFlightInspection,
}})
case errors.Is(err, flightdata.ErrRequired):
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: flightdata.ErrorMessageRequired,
}})
case flightDataFKError(err) != nil:
obj := flightDataFKError(err)
return writeFlightDataErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*obj})
default:
return writeFlightDataErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: safeInternalDetail(),
}})
}
}
func flightDataFKError(err error) *jsonapi.ErrorObject {
if err == nil {
return nil
}
var mysqlErr *mysqldriver.MySQLError
if !errors.As(err, &mysqlErr) || mysqlErr.Number != 1452 {
return nil
}
if _, errs, ok := mapOperationRelationError(err, map[string]relationFieldError{
"(`mission_id`)": {Pointer: "/data/attributes/mission_id", Detail: flightdata.ErrorMessageMissionReference},
"(`from_icao_id`)": {Pointer: "/data/attributes/from_icao_id", Detail: flightdata.ErrorMessageOriginLocationReference},
"(`from_hospital_id`)": {Pointer: "/data/attributes/from_hospital_id", Detail: flightdata.ErrorMessageOriginLocationReference},
"(`to_icao_id`)": {Pointer: "/data/attributes/to_icao_id", Detail: flightdata.ErrorMessageDestinationLocationReference},
"(`to_hospital_id`)": {Pointer: "/data/attributes/to_hospital_id", Detail: flightdata.ErrorMessageDestinationLocationReference},
"(`pilot_id`)": {Pointer: "/data/attributes/co_pilot_id", Detail: flightdata.ErrorMessageCoPilotReference},
"(`co_pilot_id`)": {Pointer: "/data/attributes/co_pilot_id", Detail: flightdata.ErrorMessageCoPilotReference},
}); ok && len(errs) > 0 {
return &errs[0]
}
return &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "referenced relation data was not found",
}
}
func writeFlightDataErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error {
enriched := make([]jsonapi.ErrorObject, 0, len(errs))
statusStr := strconv.Itoa(status)
for i := range errs {
e := errs[i]
e.Status = statusStr
def := inferFlightDataDef(status, e.Title, e.Detail, e.Source)
if strings.TrimSpace(e.Code) == "" {
e.Code = def.Code
}
if strings.TrimSpace(e.ErrorCode) == "" {
e.ErrorCode = def.ErrorCode
}
if strings.TrimSpace(e.Title) == "" {
e.Title = def.Title
}
e.Detail = def.Message
enriched = append(enriched, e)
}
return response.WriteErrors(c, status, enriched)
}
func inferFlightDataDef(status int, title, detail string, source *jsonapi.ErrorSource) apperrorsx.Def {
lowerTitle := strings.ToLower(strings.TrimSpace(title))
lowerDetail := strings.ToLower(strings.TrimSpace(detail))
pointer := ""
if source != nil {
pointer = strings.ToLower(strings.TrimSpace(source.Pointer))
}
switch status {
case fiber.StatusNotFound:
return apperrorsx.ErrFlightDataNotFound
case fiber.StatusLocked:
switch {
case strings.Contains(lowerDetail, "after flight inspection already exists"):
return apperrorsx.ErrFlightDataLockedAfterFlightInspection
case strings.Contains(lowerDetail, "fm report is completed"),
strings.Contains(lowerDetail, "flight data is locked because fm report is completed"):
return apperrorsx.ErrFlightDataLocked
default:
return apperrorsx.ErrFlightDataLocked
}
case fiber.StatusUnprocessableEntity:
switch {
case strings.Contains(lowerDetail, "invalid path uuid"), pointer == "/path/uuid":
return apperrorsx.ErrFlightDataInvalidPathUUID
case strings.Contains(lowerDetail, "field is required"), pointer == "/data/attributes/mission_id":
return apperrorsx.ErrFlightDataMissionIDRequired
case strings.Contains(lowerDetail, "mission reference"):
return apperrorsx.ErrFlightDataMissionReference
case strings.Contains(lowerDetail, "field is required"), pointer == "/data/attributes/co_pilot_id", pointer == "/data/attributes/pilot_id":
return apperrorsx.ErrFlightDataPilotIDRequired
case strings.Contains(lowerDetail, "pilot must reference an existing user"):
return apperrorsx.ErrFlightDataPilotIDRequired
case strings.Contains(lowerDetail, "invalid uuid"):
return apperrorsx.ErrFlightDataInvalidUUID
case strings.Contains(lowerDetail, "invalid rfc3339"):
return apperrorsx.ErrFlightDataInvalidRFC3339
case strings.Contains(lowerDetail, "origin location reference"):
return apperrorsx.ErrFlightDataOriginLocationReference
case strings.Contains(lowerDetail, "destination location reference"):
return apperrorsx.ErrFlightDataDestinationLocationReference
case strings.Contains(lowerDetail, "pilot reference"):
return apperrorsx.ErrFlightDataPilotReference
case strings.Contains(lowerDetail, "facility reference"):
return apperrorsx.ErrFlightDataFacilityReference
case strings.Contains(lowerDetail, "cannot be created"), strings.Contains(lowerDetail, "already exists"):
return apperrorsx.ErrFlightDataCreateBlocked
default:
return apperrorsx.ErrFlightDataInvalidPayload
}
case fiber.StatusBadRequest:
switch {
case strings.Contains(lowerTitle, "invalid json"):
return apperrorsx.ErrFlightDataInvalidJSON
case strings.Contains(lowerTitle, "create failed"):
if strings.Contains(lowerDetail, "required data is missing") {
return apperrorsx.ErrFlightDataRequired
}
return apperrorsx.ErrFlightDataCreateFailed
case strings.Contains(lowerTitle, "get failed"):
return apperrorsx.ErrFlightDataGetFailed
default:
return apperrorsx.ErrInvalidRequest
}
default:
return apperrorsx.ErrInternal
}
}
func inferFlightDataErrorCode(status int, title, detail string, source *jsonapi.ErrorSource) (string, string) {
def := inferFlightDataDef(status, title, detail, source)
return def.Code, def.ErrorCode
}
func parseSPOUpsertData(attrs requestdto.FlightDataCreateAttributes) *flightdata.SPOUpsertData {
if attrs.HESLO == nil && attrs.Logging == nil && attrs.HEC == nil {
return nil
}
data := &flightdata.SPOUpsertData{}
if attrs.HESLO != nil {
heslo := &flightdata.SPOHESLOUpsert{
Flights: make([]flightdata.SPOHESLOFlightUpsert, 0, len(attrs.HESLO.Flights)),
}
for _, f := range attrs.HESLO.Flights {
fu := flightdata.SPOHESLOFlightUpsert{ROT: f.ROT}
if id, err := uuidv7.ParseString(f.HESLORopeLabelID); err == nil {
fu.FuelTruckID = id
}
for _, s := range f.Slings {
if id, err := uuidv7.ParseString(s.SlingID); err == nil {
fu.Slings = append(fu.Slings, flightdata.SPOHESLOSlingUpsert{SlingID: id})
}
}
heslo.Flights = append(heslo.Flights, fu)
}
data.HESLO = heslo
}
if attrs.Logging != nil {
logging := &flightdata.SPOLoggingUpsert{
Slings: make([]flightdata.SPOLoggingSlingUpsert, 0, len(attrs.Logging.Slings)),
}
for _, s := range attrs.Logging.Slings {
if id, err := uuidv7.ParseString(s.SlingID); err == nil {
logging.Slings = append(logging.Slings, flightdata.SPOLoggingSlingUpsert{SlingID: id})
}
}
data.Logging = logging
}
if attrs.HEC != nil {
hec := &flightdata.SPOHECUpsert{
Flights: make([]flightdata.SPOHECFlightUpsert, 0, len(attrs.HEC.Flights)),
Slings: make([]flightdata.SPOHECSlingUpsert, 0, len(attrs.HEC.Slings)),
Loads: make([]flightdata.SPOHECLoadUpsert, 0, len(attrs.HEC.Loads)),
}
for _, f := range attrs.HEC.Flights {
fu := flightdata.SPOHECFlightUpsert{ROT: f.ROT, HECCycle: f.HECCycle}
if id, err := uuidv7.ParseString(f.EquipmentID); err == nil {
fu.EquipmentID = id
}
hec.Flights = append(hec.Flights, fu)
}
for _, s := range attrs.HEC.Slings {
if id, err := uuidv7.ParseString(s.SlingID); err == nil {
hec.Slings = append(hec.Slings, flightdata.SPOHECSlingUpsert{SlingID: id})
}
}
for _, l := range attrs.HEC.Loads {
hec.Loads = append(hec.Loads, flightdata.SPOHECLoadUpsert{
LoadCategory: l.LoadCategory,
Quantity: l.Quantity,
})
}
data.HEC = hec
}
return data
}
// --- Response building (flight data → JSON:API resource) ---
func buildFlightDataResourceWithService(ctx context.Context, svc flightdata.Service, row *flightdata.FlightData) (responsedto.FlightDataResource, error) {
resource := responsedto.FlightDataResource{
Type: sharedconst.FlightDataResourceType,
ID: idString(row.ID),
Attributes: buildBaseFlightDataAttributes(row),
}
heslo, logging, hec, err := buildFlightDataSPODetails(ctx, svc, row)
if err != nil {
return responsedto.FlightDataResource{}, err
}
resource.Attributes.HESLO = heslo
resource.Attributes.Logging = logging
resource.Attributes.HEC = hec
return resource, nil
}
func buildBaseFlightDataAttributes(row *flightdata.FlightData) responsedto.FlightDataAttributes {
return responsedto.FlightDataAttributes{
Status: func() string {
if status := strings.TrimSpace(row.Status); status != "" {
return status
}
return flightdata.StatusInProgress
}(),
Mission: missionRefFlightData(row),
RosterDetails: rosterDetailsFromFlightData(row),
CoPilot: coPilotRefFlightData(row),
From: fromLocationRefFlightData(row),
To: toLocationRefFlightData(row),
FlightTakeOff: formatTimeOrEmpty(row.FlightTakeOff),
FlightLanding: formatTimeOrEmpty(row.FlightLanding),
FlightDuration: int64(row.FlightDuration.Seconds()),
FlightRED: int64(row.FlightRED.Seconds()),
MaxN1: row.MaxN1,
MaxN2: row.MaxN2,
PaxCount: row.PaxCount,
LandingCount: row.LandingCount,
RotorBrakeCycle: row.RotorBrakeCycle,
HookReleases: row.HookReleases,
TicketNo: row.TicketNo,
Engine: row.Engine,
DeliveryNoteNumber: row.DeliveryNoteNumber,
CustomerName: row.CustomerName,
FlightPlanDistance: row.FlightPlanDistance,
FlightPlanTimeSeconds: int64(row.FlightPlanTime.Seconds()),
FlightPlanTrueCourse: row.FlightPlanTrueCourse,
FuelBeforeFlight: row.FuelBeforeFlight,
FuelUpload: row.FuelUpload,
FuelAfterFlight: row.FuelAfterFlight,
FuelPlanning: row.FuelPlanning,
FlightPositioning: row.FlightPositioning,
OtherInformation: row.OtherInformation,
CreatedAt: formatTimeOrEmpty(row.CreatedAt),
CreatedBy: uuidStringOrEmpty(row.CreatedBy),
UpdatedAt: formatTimeOrEmpty(row.UpdatedAt),
UpdatedBy: uuidStringOrEmpty(row.UpdatedBy),
}
}
func rosterDetailsFromFlightData(row *flightdata.FlightData) *responsedto.TakeoverDutyRosterDetailsResponse {
if row == nil || row.Mission == nil || row.Mission.Flight == nil || row.Mission.Flight.Takeover == nil {
return nil
}
takeoverRow := row.Mission.Flight.Takeover
if len(takeoverRow.RosterCrews) == 0 && len(takeoverRow.OtherPeople) == 0 {
return nil
}
resource := responsedto.DutyRosterResource{
Attributes: responsedto.DutyRosterAttributes{
Detail: responsedto.DutyRosterDetailsResponse{
Pilot: []responsedto.DutyRosterPilotCrew{},
Doctor: []responsedto.DutyRosterCrew{},
Rescuer: []responsedto.DutyRosterCrew{},
Helper: []responsedto.DutyRosterCrew{},
OtherPerson: []responsedto.DutyRosterCrew{},
},
},
}
resource = buildDutyRosterResourceFromTakeoverRows(resource, takeoverRow.RosterCrews, row.Mission.Flight)
resource = buildDutyRosterResourceFromOtherPeople(resource, takeoverRow.OtherPeople)
detail := takeoverDutyRosterDetailsFromRosterDetails(resource.Attributes.Detail)
return &detail
}
func formatTimeOrEmpty(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func buildFlightDataSPODetails(
ctx context.Context,
svc flightdata.Service,
row *flightdata.FlightData,
) (
*responsedto.FlightDataSPOHESLODetails,
*responsedto.FlightDataSPOLoggingDetails,
*responsedto.FlightDataSPOHECDetails,
error,
) {
if svc == nil || row == nil || row.Mission == nil || strings.ToUpper(strings.TrimSpace(row.Mission.Type)) != "SPO" {
return nil, nil, nil, nil
}
if len(row.ID) != 16 {
return nil, nil, nil, nil
}
details, err := svc.GetSPODetailsByFlightDataID(ctx, row.ID)
if err != nil || details == nil {
return nil, nil, nil, err
}
heslo := &responsedto.FlightDataSPOHESLODetails{
Flights: make([]responsedto.FlightDataSPOHESLOFlight, 0, len(details.HESLO.Flights)),
}
logging := &responsedto.FlightDataSPOLoggingDetails{
Slings: make([]responsedto.FlightDataSPOLoggingSling, 0, len(details.Logging.Slings)),
}
hec := &responsedto.FlightDataSPOHECDetails{
Flights: make([]responsedto.FlightDataSPOHECFlight, 0, len(details.HEC.Flights)),
Slings: make([]responsedto.FlightDataSPOHECSling, 0, len(details.HEC.Slings)),
Loads: make([]responsedto.FlightDataSPOHECLoad, 0, len(details.HEC.Loads)),
}
slingsByFlightID := make(map[string][]responsedto.FlightDataSPOHESLOSling, len(details.HESLO.Slings))
for i := range details.HESLO.Slings {
flightID := idString(details.HESLO.Slings[i].HESLOFlightID)
slingsByFlightID[flightID] = append(slingsByFlightID[flightID], responsedto.FlightDataSPOHESLOSling{
ID: idString(details.HESLO.Slings[i].ID),
SlingID: idString(details.HESLO.Slings[i].SlingID),
SlingName: strings.TrimSpace(details.HESLO.Slings[i].SlingName),
})
}
for i := range details.HESLO.Flights {
flightID := idString(details.HESLO.Flights[i].ID)
heslo.Flights = append(heslo.Flights, responsedto.FlightDataSPOHESLOFlight{
ID: flightID,
ROT: details.HESLO.Flights[i].ROT,
HESLORopeLabelID: idString(details.HESLO.Flights[i].FuelTruckID),
HESLORopeLabelName: strings.TrimSpace(details.HESLO.Flights[i].FuelTruckName),
Slings: slingsByFlightID[flightID],
})
}
for i := range details.Logging.Slings {
logging.Slings = append(logging.Slings, responsedto.FlightDataSPOLoggingSling{
ID: idString(details.Logging.Slings[i].ID),
SlingID: idString(details.Logging.Slings[i].SlingID),
SlingName: strings.TrimSpace(details.Logging.Slings[i].SlingName),
})
}
for i := range details.HEC.Flights {
hec.Flights = append(hec.Flights, responsedto.FlightDataSPOHECFlight{
ID: idString(details.HEC.Flights[i].ID),
ROT: details.HEC.Flights[i].ROT,
HECCycle: details.HEC.Flights[i].HECCycle,
EquipmentID: idString(details.HEC.Flights[i].EquipmentID),
EquipmentName: strings.TrimSpace(details.HEC.Flights[i].EquipmentName),
})
}
for i := range details.HEC.Slings {
hec.Slings = append(hec.Slings, responsedto.FlightDataSPOHECSling{
ID: idString(details.HEC.Slings[i].ID),
SlingID: idString(details.HEC.Slings[i].SlingID),
SlingName: strings.TrimSpace(details.HEC.Slings[i].SlingName),
})
}
for i := range details.HEC.Loads {
hec.Loads = append(hec.Loads, responsedto.FlightDataSPOHECLoad{
ID: idString(details.HEC.Loads[i].ID),
LoadCategory: strings.TrimSpace(details.HEC.Loads[i].LoadCategory),
Quantity: details.HEC.Loads[i].Quantity,
})
}
return heslo, logging, hec, nil
}