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 }