package handlers import ( "context" "errors" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" afterflightinspection "wucher/internal/domain/after_flight_inspection" authdomain "wucher/internal/domain/auth" "wucher/internal/domain/flight" flightdata "wucher/internal/domain/flight_data" fmreport "wucher/internal/domain/fm_report" helicopterdomain "wucher/internal/domain/helicopter" "wucher/internal/domain/mission" reserveac "wucher/internal/domain/reserve_ac" "wucher/internal/service" sharedconst "wucher/internal/shared/const" "wucher/internal/shared/pkg/appctx" "wucher/internal/shared/pkg/apperrorsx" "wucher/internal/shared/pkg/reserveacutil" "wucher/internal/shared/pkg/userctx" "wucher/internal/shared/pkg/uuidv7" 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 FlightHandler struct { svc flight.FlightService afterFlightInspection afterflightinspection.Service fmReport fmreport.Service takeover takeoverLookupService reserveAc reserveac.ReserveAcService helicopter helicopterdomain.Service mission mission.Service flightData flightdata.Service auth *service.AuthService validate *validators.Validator } func NewFlightHandler(svc flight.FlightService) *FlightHandler { return &FlightHandler{ svc: svc, validate: validators.New(), } } func (h *FlightHandler) WithReserveAcService(reserveAcSvc reserveac.ReserveAcService) *FlightHandler { h.reserveAc = reserveAcSvc return h } func (h *FlightHandler) WithAfterFlightInspectionService(afterSvc afterflightinspection.Service) *FlightHandler { h.afterFlightInspection = afterSvc return h } func (h *FlightHandler) WithFMReportService(reportSvc fmreport.Service) *FlightHandler { h.fmReport = reportSvc return h } func (h *FlightHandler) WithTakeoverService(takeoverSvc takeoverLookupService) *FlightHandler { h.takeover = takeoverSvc return h } func (h *FlightHandler) WithHelicopterService(heliSvc helicopterdomain.Service) *FlightHandler { h.helicopter = heliSvc return h } func (h *FlightHandler) WithMissionService(missionSvc mission.Service) *FlightHandler { h.mission = missionSvc return h } func (h *FlightHandler) WithFlightDataService(flightDataSvc flightdata.Service) *FlightHandler { h.flightData = flightDataSvc return h } func (h *FlightHandler) WithAuthService(authSvc *service.AuthService) *FlightHandler { h.auth = authSvc return h } // Create keeps internal compatibility but is no longer exposed as public HTTP route. func (h *FlightHandler) Create(c *fiber.Ctx) error { var req requestdto.FlightCreateRequest if err := c.BodyParser(&req); err != nil { return writeFlightErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeFlightErrors(c, fiber.StatusUnprocessableEntity, errs) } date, err := time.Parse("2006-01-02", strings.TrimSpace(req.Data.Attributes.Date)) if err != nil { return writeFlightErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "date must be YYYY-MM-DD", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/date"}, }}) } if _, errObj := parseOptionalUUIDFlight(req.Data.Attributes.DutyRosterID, "/data/attributes/duty_roster_id"); errObj != nil { return writeFlightErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj}) } if _, errObj := parseOptionalUUIDFlight(req.Data.Attributes.ReserveAcID, "/data/attributes/reserve_ac_id"); errObj != nil { return writeFlightErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj}) } row := &flight.Flight{ Date: date.UTC(), CreatedBy: actorUserID(c), UpdatedBy: actorUserID(c), } if err := h.svc.Create(c.UserContext(), row); err != nil { return writeFlightErrors(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 { return response.Write(c, fiber.StatusCreated, h.flightMutationResource(c.UserContext(), created)) } return response.Write(c, fiber.StatusCreated, h.flightMutationResource(c.UserContext(), row)) } // GetFlightsByAuthUser godoc // @Summary Get flights for current auth user (from duty roster assignment) // @Tags Flight // @Produce json // @Description Returns flights assigned to current auth user, including workflow steps (`duty_roster`, `reserve_ac`, `mission`, `after_flight_inspection`, `fm_report`). // @Description Each step includes `status`, `completed_at`, and `completed_by` for consistent FE rendering. // @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.FlightListResponse // @Failure 401 {object} jsonapi.ErrorDocument // @Router /api/v1/flights/get [get] func (h *FlightHandler) GetByAuthUser(c *fiber.Ctx) error { userID := actorUserID(c) if len(userID) == 0 { return writeFlightErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } 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.ListByUserID(c.UserContext(), userID, pageSize, offset) if err != nil { return writeFlightErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Get failed", Detail: safeInternalDetail(), }}) } missionsByFlightID, err := h.prefetchMissionsByFlightID(c.UserContext(), rows) if err != nil { return writeFlightErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } dailyInspectionCache := make(map[string]*responsedto.FlightDailyInspectionSummary) afterFlightCache := make(map[string]responsedto.FlightStepItem) fmReportCache := make(map[string]responsedto.FlightStepItem) data := make([]responsedto.FlightResource, 0, len(rows)) for i := range rows { data = append(data, h.flightResourceForList(c.UserContext(), c, &rows[i], missionsByFlightID[string(rows[i].ID)], dailyInspectionCache, afterFlightCache, fmReportCache)) } return response.WriteWithMeta(c, fiber.StatusOK, data, map[string]any{ "total": total, }) } // ListFlights godoc // @Summary List flights // @Description Supports `search`, `filter[search]`, `sort`, `page[number]`, `page[size]`, `page`, `size`, and `limit`. // @Description Supports `date` and `filter[date]` for exact flight date filtering (`YYYY-MM-DD`). // @Description Returns paginated flights with workflow steps (`takeover`, `mission`, `after_flight_inspection`, `fm_report`). // @Description Each step includes `status`, `completed_at`, and `completed_by` for consistent FE rendering. // @Tags Flight // @Produce json // @Param search query string false "Search text" // @Param filter[search] query string false "Search text (JSON:API style)" // @Param date query string false "Filter by flight date (YYYY-MM-DD)" // @Param filter[date] query string false "Filter by flight date (YYYY-MM-DD) (JSON:API style)" // @Param sort query string false "Sort field" // @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.FlightListResponse // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/flights/get-all [get] func (h *FlightHandler) List(c *fiber.Ctx) error { filter := c.Query("filter[search]") if filter == "" { filter = c.Query("search") } dateFilter := strings.TrimSpace(c.Query("filter[date]")) if dateFilter == "" { dateFilter = strings.TrimSpace(c.Query("date")) } if dateFilter != "" { if _, err := time.Parse("2006-01-02", dateFilter); err != nil { return writeFlightErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "date must be YYYY-MM-DD", Source: &jsonapi.ErrorSource{Pointer: "/date"}, }}) } } 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 sort := c.Query("sort") rows, total, err := h.listFlightsForCurrentUser(c, filter, dateFilter, sort, 0, 0) if err != nil { return writeFlightErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } data := make([]responsedto.FlightResource, 0, len(rows)) for i := range rows { data = append(data, h.flightResource(c.UserContext(), c, &rows[i])) } return response.WriteWithMeta(c, fiber.StatusOK, data, map[string]any{ "total": total, }) } func (h *FlightHandler) listFlightsForCurrentUser(c *fiber.Ctx, filter, date, sort string, limit, offset int) ([]flight.Flight, int64, error) { if h == nil || h.svc == nil { return nil, 0, errors.New("flight service not configured") } if h.auth == nil { return h.svc.List(c.UserContext(), filter, date, sort, limit, offset) } userID := appctx.GetUserID(c.UserContext()) if len(userID) == 0 { return nil, 0, errors.New("missing auth context") } user, err := h.auth.GetUserByID(c.UserContext(), userID) if err != nil { return nil, 0, err } if user == nil { return nil, 0, errors.New("user not found") } if flightUserHasRole(user, "admin") { return h.svc.List(c.UserContext(), filter, date, sort, limit, offset) } return h.svc.ListByCreatedBy(c.UserContext(), userID, filter, date, sort, limit, offset) } func flightUserHasRole(user *authdomain.User, roleName string) bool { if user == nil { return false } for i := range user.Roles { if strings.EqualFold(strings.TrimSpace(user.Roles[i].Name), roleName) { return true } } return false } func (h *FlightHandler) flightResource(ctx context.Context, c *fiber.Ctx, row *flight.Flight) responsedto.FlightResource { missionStep := flightMissionStep(row) takeoverStep := flightTakeoverStep(c.Context(), row, h.takeover) afterFlightInspectionStep := h.flightAfterFlightInspectionStep(ctx, row) fmReportStep := h.flightFMReportStep(ctx, row) var takeover *responsedto.FlightTakeoverSummary var draft []responsedto.FlightDraftGroup totalDraft := 0 if row != nil && len(row.TakeoverAcID) == 16 { takeover = flightTakeoverSummary(ctx, h.reserveAc, row) if takeover == nil { takeover = &responsedto.FlightTakeoverSummary{} } takeover.ID = idString(row.TakeoverAcID) takeover.Helicopter = h.helicopterSummaryFromTakeover(ctx, row) takeover.RosterDetail = h.takeoverRosterDetailSummary(ctx, row) } missionID := "" if h != nil && h.mission != nil && row != nil && len(row.ID) == 16 { rows, err := h.mission.ListByFlightID(ctx, row.ID) if err == nil && len(rows) > 0 { missionID = idString(rows[0].ID) grouped := make(map[string][]responsedto.FlightDraftItem) summaries := make(map[string]responsedto.FlightMissionTypeSummary) missionComplete := true var completedAt time.Time var completedBy []byte for i := range rows { rowComplete := h.missionFlightDataComplete(ctx, rows[i].ID) if !rowComplete { missionComplete = false } if rowComplete && rows[i].UpdatedAt.After(completedAt) { completedAt = rows[i].UpdatedAt completedBy = rows[i].UpdatedBy } missionType := strings.ToUpper(strings.TrimSpace(rows[i].Type)) status := mission.StatusDraft if rowComplete { status = mission.StatusCompleted } grouped[missionType] = append(grouped[missionType], responsedto.FlightDraftItem{ ID: idString(rows[i].ID), Type: missionType, Status: status, FlightDataID: idString(rows[i].FlightDataID), }) sum := summaries[missionType] sum.Type = missionType sum.Draft++ if status == mission.StatusCompleted { sum.Completed++ } summaries[missionType] = sum } for _, missionType := range []string{sharedconst.MissionTypeHEMS, sharedconst.MissionTypeCAT, sharedconst.MissionTypeSPO, sharedconst.MissionTypeNCO} { items := grouped[missionType] if len(items) == 0 { continue } draft = append(draft, responsedto.FlightDraftGroup{ Type: missionType, Items: items, }) totalDraft += len(items) } missionStep.ID = missionID missionStep.Status = "in_progress" if missionComplete { missionStep.Status = "completed" } missionStep.Complete = missionComplete if missionComplete && !completedAt.IsZero() { missionStep.CompletedAt = completedAt.UTC().Format(time.RFC3339) missionStep.CompletedBy = userctx.GetDisplayName(ctx, completedBy) } missionStep.MissionType = make([]responsedto.FlightMissionTypeSummary, 0, len(draft)) for _, missionType := range []string{sharedconst.MissionTypeHEMS, sharedconst.MissionTypeCAT, sharedconst.MissionTypeSPO, sharedconst.MissionTypeNCO} { if sum, ok := summaries[missionType]; ok { missionStep.MissionType = append(missionStep.MissionType, sum) } } } } steps := []responsedto.FlightStepItem{missionStep} if row != nil && len(row.TakeoverAcID) == 16 { steps = []responsedto.FlightStepItem{takeoverStep, missionStep} if afterFlightInspectionStep.Phase != "" { steps = append(steps, afterFlightInspectionStep) } if fmReportStep.Phase != "" { steps = append(steps, fmReportStep) } } updatedAt, updatedBy := updatedAuditOrCreated(row.UpdatedAt, row.CreatedAt, row.UpdatedBy, row.UpdatedByName, row.CreatedBy, row.CreatedByName) return responsedto.FlightResource{ Type: "flight", ID: idString(row.ID), Attributes: responsedto.FlightAttributes{ MissionID: missionID, Code: stringPtrOrEmpty(row.MissionCode), Date: row.Date.UTC().Format("2006-01-02"), TotalDraft: totalDraft, Takeover: takeover, Steps: steps, Draft: draft, CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), CreatedBy: userRef(row.CreatedBy, row.CreatedByName), UpdatedAt: updatedAt, UpdatedBy: updatedBy, DeletedAt: timeStringOrEmpty(row.DeletedAt), DeletedBy: userRef(row.DeletedBy, ""), }, } } func (h *FlightHandler) flightResourceForList(ctx context.Context, c *fiber.Ctx, row *flight.Flight, missions []mission.Mission, dailyInspectionCache map[string]*responsedto.FlightDailyInspectionSummary, afterFlightCache map[string]responsedto.FlightStepItem, fmReportCache map[string]responsedto.FlightStepItem) responsedto.FlightResource { missionStep := flightMissionStep(row) takeoverStep := flightTakeoverStep(c.Context(), row, nil) afterFlightInspectionStep := h.flightAfterFlightInspectionStepCached(ctx, row, afterFlightCache) fmReportStep := h.flightFMReportStepCached(ctx, row, fmReportCache) var takeover *responsedto.FlightTakeoverSummary var draft []responsedto.FlightDraftGroup totalDraft := 0 if row != nil && len(row.TakeoverAcID) == 16 { takeover = flightTakeoverSummaryForList(ctx, h.reserveAc, row, dailyInspectionCache) if takeover == nil { takeover = &responsedto.FlightTakeoverSummary{} } takeover.ID = idString(row.TakeoverAcID) takeover.Helicopter = h.helicopterSummaryFromTakeoverForList(ctx, row) takeover.RosterDetail = h.takeoverRosterDetailSummary(ctx, row) } missionID := "" if len(missions) > 0 { missionID = idString(missions[0].ID) grouped := make(map[string][]responsedto.FlightDraftItem) summaries := make(map[string]responsedto.FlightMissionTypeSummary) missionComplete := true var completedAt time.Time var completedBy []byte for i := range missions { rowComplete := strings.EqualFold(strings.TrimSpace(missions[i].FlightDataStatus), flightdata.StatusCompleted) if !rowComplete { missionComplete = false } if rowComplete && missions[i].UpdatedAt.After(completedAt) { completedAt = missions[i].UpdatedAt completedBy = missions[i].UpdatedBy } missionType := strings.ToUpper(strings.TrimSpace(missions[i].Type)) status := mission.StatusDraft if rowComplete { status = mission.StatusCompleted } grouped[missionType] = append(grouped[missionType], responsedto.FlightDraftItem{ ID: idString(missions[i].ID), Type: missionType, Status: status, FlightDataID: idString(missions[i].FlightDataID), }) sum := summaries[missionType] sum.Type = missionType sum.Draft++ if status == mission.StatusCompleted { sum.Completed++ } summaries[missionType] = sum } for _, missionType := range []string{sharedconst.MissionTypeHEMS, sharedconst.MissionTypeCAT, sharedconst.MissionTypeSPO, sharedconst.MissionTypeNCO} { items := grouped[missionType] if len(items) == 0 { continue } draft = append(draft, responsedto.FlightDraftGroup{ Type: missionType, Items: items, }) totalDraft += len(items) } missionStep.ID = missionID missionStep.Status = "in_progress" if missionComplete { missionStep.Status = "completed" } missionStep.Complete = missionComplete if missionComplete && !completedAt.IsZero() { missionStep.CompletedAt = completedAt.UTC().Format(time.RFC3339) missionStep.CompletedBy = userctx.GetDisplayName(ctx, completedBy) } missionStep.MissionType = make([]responsedto.FlightMissionTypeSummary, 0, len(draft)) for _, missionType := range []string{sharedconst.MissionTypeHEMS, sharedconst.MissionTypeCAT, sharedconst.MissionTypeSPO, sharedconst.MissionTypeNCO} { if sum, ok := summaries[missionType]; ok { missionStep.MissionType = append(missionStep.MissionType, sum) } } } steps := []responsedto.FlightStepItem{missionStep} if row != nil && len(row.TakeoverAcID) == 16 { steps = []responsedto.FlightStepItem{takeoverStep, missionStep} if afterFlightInspectionStep.Phase != "" { steps = append(steps, afterFlightInspectionStep) } if fmReportStep.Phase != "" { steps = append(steps, fmReportStep) } } updatedAt, updatedBy := updatedAuditOrCreated(row.UpdatedAt, row.CreatedAt, row.UpdatedBy, row.UpdatedByName, row.CreatedBy, row.CreatedByName) return responsedto.FlightResource{ Type: "flight", ID: idString(row.ID), Attributes: responsedto.FlightAttributes{ MissionID: missionID, Code: stringPtrOrEmpty(row.MissionCode), Date: row.Date.UTC().Format("2006-01-02"), TotalDraft: totalDraft, Takeover: takeover, Steps: steps, Draft: draft, CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), CreatedBy: userRef(row.CreatedBy, row.CreatedByName), UpdatedAt: updatedAt, UpdatedBy: updatedBy, DeletedAt: timeStringOrEmpty(row.DeletedAt), DeletedBy: userRef(row.DeletedBy, ""), }, } } func (h *FlightHandler) prefetchMissionsByFlightID(ctx context.Context, rows []flight.Flight) (map[string][]mission.Mission, error) { if h == nil || h.mission == nil || len(rows) == 0 { return map[string][]mission.Mission{}, nil } flightIDs := make([][]byte, 0, len(rows)) for i := range rows { if len(rows[i].ID) == 16 { flightIDs = append(flightIDs, rows[i].ID) } } if len(flightIDs) == 0 { return map[string][]mission.Mission{}, nil } missions, err := h.mission.ListByFlightIDs(ctx, flightIDs) if err != nil { return nil, err } grouped := make(map[string][]mission.Mission, len(flightIDs)) for i := range missions { key := string(missions[i].FlightID) grouped[key] = append(grouped[key], missions[i]) } return grouped, nil } func (h *FlightHandler) flightAfterFlightInspectionStepCached(ctx context.Context, row *flight.Flight, cache map[string]responsedto.FlightStepItem) responsedto.FlightStepItem { if row == nil || row.Takeover == nil || row.Takeover.ReserveAc == nil || row.Takeover.ReserveAc.Inspection == nil || len(row.Takeover.ReserveAc.Inspection.ID) != 16 { return responsedto.FlightStepItem{} } inspectionID := string(row.Takeover.ReserveAc.Inspection.ID) if cache != nil { if step, ok := cache[inspectionID]; ok { return step } } step := h.flightAfterFlightInspectionStep(ctx, row) if cache != nil { cache[inspectionID] = step } return step } func (h *FlightHandler) flightFMReportStepCached(ctx context.Context, row *flight.Flight, cache map[string]responsedto.FlightStepItem) responsedto.FlightStepItem { if row == nil || row.Takeover == nil || len(row.ID) != 16 { return responsedto.FlightStepItem{} } flightID := string(row.ID) if cache != nil { if step, ok := cache[flightID]; ok { return step } } step := h.flightFMReportStep(ctx, row) if cache != nil { cache[flightID] = step } return step } func flightTakeoverSummaryForList(ctx context.Context, reserveAcSvc reserveac.ReserveAcService, row *flight.Flight, cache map[string]*responsedto.FlightDailyInspectionSummary) *responsedto.FlightTakeoverSummary { if row == nil || row.Takeover == nil { return nil } reserveRow := row.Takeover.ReserveAc if reserveRow == nil || len(reserveRow.AircraftID) == 0 { if reserveAcSvc == nil || len(row.Takeover.ReserveAcID) != 16 { return nil } var err error reserveRow, err = reserveAcSvc.GetByID(ctx, row.Takeover.ReserveAcID) if err != nil || reserveRow == nil || len(reserveRow.AircraftID) == 0 { return nil } } out := &responsedto.FlightTakeoverSummary{} heliID := string(reserveRow.AircraftID) if cache != nil { if daily, ok := cache[heliID]; ok { out.DailyInspection = daily } } if out.DailyInspection == nil && reserveAcSvc != nil { lastDaily, _ := reserveAcSvc.GetLastDailyInspectionByHelicopterID(ctx, reserveRow.AircraftID) if lastDaily != nil { out.DailyInspection = &responsedto.FlightDailyInspectionSummary{ ID: idString(lastDaily.ID), Name: strings.TrimSpace(lastDaily.Name), ShortName: strings.TrimSpace(lastDaily.ShortName), Date: lastDaily.At.UTC().Format("02.01.2006"), UTCTime: lastDaily.At.UTC().Format("15:04"), } if cache != nil { cache[heliID] = out.DailyInspection } } } return out } func (h *FlightHandler) helicopterSummaryFromTakeoverForList(ctx context.Context, row *flight.Flight) *responsedto.FlightHelicopterSummary { if row == nil || row.Takeover == nil || row.Takeover.ReserveAc == nil { return h.helicopterSummaryFromTakeover(ctx, row) } reserveRow := row.Takeover.ReserveAc if reserveRow.Aircraft == nil { return h.helicopterSummaryFromTakeover(ctx, row) } heliRow := reserveRow.Aircraft return &responsedto.FlightHelicopterSummary{ AOG: heliRow.AirOnGround, Designation: strings.TrimSpace(heliRow.Designation), ID: idString(heliRow.ID), Identifier: strings.TrimSpace(heliRow.Identifier), IsActive: heliRow.IsActive, Type: strings.TrimSpace(heliRow.Type), } } func (h *FlightHandler) missionFlightDataComplete(ctx context.Context, missionID []byte) bool { if h == nil || h.flightData == nil || len(missionID) != 16 { return false } fd, err := h.flightData.GetByMissionID(ctx, missionID) if err != nil || fd == nil { return false } return flightdata.IsComplete(fd) } func (h *FlightHandler) flightMutationResource(ctx context.Context, row *flight.Flight) responsedto.FlightResource { if row == nil { return responsedto.FlightResource{} } takeover := &responsedto.FlightTakeoverSummary{ ID: idString(row.TakeoverAcID), } missionID := h.flightMissionID(ctx, row) updatedAt, updatedBy := updatedAuditOrCreated(row.UpdatedAt, row.CreatedAt, row.UpdatedBy, row.UpdatedByName, row.CreatedBy, row.CreatedByName) return responsedto.FlightResource{ Type: "flight", ID: idString(row.ID), Attributes: responsedto.FlightAttributes{ MissionID: missionID, Code: stringPtrOrEmpty(row.MissionCode), Date: row.Date.UTC().Format("2006-01-02"), TotalDraft: 0, Takeover: takeover, Steps: nil, Draft: nil, CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), CreatedBy: userRef(row.CreatedBy, row.CreatedByName), UpdatedAt: updatedAt, UpdatedBy: updatedBy, DeletedAt: timeStringOrEmpty(row.DeletedAt), DeletedBy: userRef(row.DeletedBy, ""), }, } } func (h *FlightHandler) flightMissionID(ctx context.Context, row *flight.Flight) string { if h == nil || h.mission == nil || row == nil || len(row.ID) != 16 { return "" } rows, err := h.mission.ListByFlightID(ctx, row.ID) if err != nil || len(rows) == 0 { return "" } return idString(rows[0].ID) } func flightMissionStep(row *flight.Flight) responsedto.FlightStepItem { if row == nil { return responsedto.FlightStepItem{Phase: "mission", Step: 2, Status: "unassigned"} } return responsedto.FlightStepItem{Phase: "mission", Step: 2, Status: "unassigned"} } func (h *FlightHandler) flightAfterFlightInspectionStep(ctx context.Context, row *flight.Flight) responsedto.FlightStepItem { if row == nil || row.Takeover == nil || row.Takeover.ReserveAc == nil || row.Takeover.ReserveAc.Inspection == nil || len(row.Takeover.ReserveAc.Inspection.ID) != 16 { return responsedto.FlightStepItem{} } inspectionID := row.Takeover.ReserveAc.Inspection.ID if h == nil || h.afterFlightInspection == nil { return responsedto.FlightStepItem{} } afterRow, err := h.afterFlightInspection.GetByFlightInspectionID(ctx, inspectionID) if err != nil || afterRow == nil { return responsedto.FlightStepItem{} } return responsedto.FlightStepItem{ Phase: "after_flight_inspection", Step: 3, ID: idString(afterRow.ID), Status: "completed", Complete: true, CompletedAt: timeStringOrEmptyRefTime(&afterRow.UpdatedAt), CompletedBy: uuidStringOrEmpty(afterRow.UpdatedBy), } } func (h *FlightHandler) flightFMReportStep(ctx context.Context, row *flight.Flight) responsedto.FlightStepItem { if row == nil || row.Takeover == nil || h == nil || h.fmReport == nil || len(row.ID) != 16 { return responsedto.FlightStepItem{} } reportRow, err := h.fmReport.GetByFlightID(ctx, row.ID) if err != nil || reportRow == nil { return responsedto.FlightStepItem{} } step := responsedto.FlightStepItem{ Phase: "fm_report", Step: 4, ID: idString(reportRow.ID), Status: "in_progress", } if reportRow.CompletedAt != nil { step.Status = "completed" step.Complete = true step.CompletedAt = timeStringOrEmpty(reportRow.CompletedAt) step.CompletedBy = uuidStringOrEmpty(reportRow.CompletedBy) } return step } // ResolveHelicopterIDByTakeover resolves the flown aircraft's helicopter id from // a takeover id via takeover -> reserve_ac -> aircraft. Returns nil when it // cannot be resolved. func (h *FlightHandler) ResolveHelicopterIDByTakeover(ctx context.Context, takeoverID []byte) []byte { if h == nil || h.takeover == nil || h.reserveAc == nil || len(takeoverID) != 16 { return nil } takeoverRow, err := h.takeover.GetByID(ctx, takeoverID) if err != nil || takeoverRow == nil || len(takeoverRow.ReserveAcID) != 16 { return nil } reserveRow, err := h.reserveAc.GetByID(ctx, takeoverRow.ReserveAcID) if err != nil || reserveRow == nil || len(reserveRow.AircraftID) != 16 { return nil } return reserveRow.AircraftID } func (h *FlightHandler) ResolveHelicopterForReportCode(ctx context.Context, takeoverID []byte) ([]byte, string) { helicopterID := h.ResolveHelicopterIDByTakeover(ctx, takeoverID) if len(helicopterID) != 16 || h.helicopter == nil { return nil, "" } heliRow, err := h.helicopter.GetByID(ctx, helicopterID) if err != nil || heliRow == nil { return helicopterID, "" } return helicopterID, strings.TrimSpace(heliRow.Identifier) } func (h *FlightHandler) helicopterSummaryFromTakeover(ctx context.Context, row *flight.Flight) *responsedto.FlightHelicopterSummary { if row == nil || row.Takeover == nil || h == nil || h.helicopter == nil || h.reserveAc == nil { return nil } reserveID := row.Takeover.ReserveAcID if len(reserveID) != 16 { return nil } reserveRow, err := h.reserveAc.GetByID(ctx, reserveID) if err != nil || reserveRow == nil || len(reserveRow.AircraftID) != 16 { return nil } heliRow, err := h.helicopter.GetByID(ctx, reserveRow.AircraftID) if err != nil || heliRow == nil { return nil } return &responsedto.FlightHelicopterSummary{ AOG: heliRow.AirOnGround, Designation: strings.TrimSpace(heliRow.Designation), ID: idString(heliRow.ID), Identifier: strings.TrimSpace(heliRow.Identifier), IsActive: heliRow.IsActive, Type: strings.TrimSpace(heliRow.Type), } } func stringOrFallback(name string, id []byte) string { if trimmed := strings.TrimSpace(name); trimmed != "" { return trimmed } return uuidStringOrEmpty(id) } func flightTakeoverSummary(ctx context.Context, reserveAcSvc reserveac.ReserveAcService, row *flight.Flight) *responsedto.FlightTakeoverSummary { if row == nil || reserveAcSvc == nil { return nil } var reserveID []byte if row.Takeover != nil && len(row.Takeover.ReserveAcID) == 16 { reserveID = row.Takeover.ReserveAcID } if len(reserveID) != 16 { return nil } reserveRow, err := reserveAcSvc.GetByID(ctx, reserveID) if err != nil || reserveRow == nil || len(reserveRow.AircraftID) == 0 { return nil } out := &responsedto.FlightTakeoverSummary{} lastDaily, _ := reserveAcSvc.GetLastDailyInspectionByHelicopterID(ctx, reserveRow.AircraftID) if lastDaily != nil { out.DailyInspection = &responsedto.FlightDailyInspectionSummary{ ID: idString(lastDaily.ID), Name: strings.TrimSpace(lastDaily.Name), ShortName: strings.TrimSpace(lastDaily.ShortName), Date: lastDaily.At.UTC().Format("02.01.2006"), UTCTime: lastDaily.At.UTC().Format("15:04"), } } return out } func (h *FlightHandler) takeoverRosterDetailSummary(ctx context.Context, row *flight.Flight) *responsedto.TakeoverDutyRosterDetailsResponse { if h == nil || h.takeover == nil || row == nil || len(row.TakeoverAcID) != 16 { return nil } takeoverRow := row.Takeover if takeoverRow == nil || len(takeoverRow.RosterCrews) == 0 && len(takeoverRow.OtherPeople) == 0 { loaded, err := h.takeover.GetByID(ctx, row.TakeoverAcID) if err != nil || loaded == nil { return nil } takeoverRow = loaded } if takeoverRow == nil { 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) resource = buildDutyRosterResourceFromOtherPeople(resource, takeoverRow.OtherPeople) detail := takeoverDutyRosterDetailsFromRosterDetails(resource.Attributes.Detail) return &detail } func flightDutyRosterStep(row *flight.Flight) responsedto.FlightStepItem { if row == nil || len(row.TakeoverAcID) != 16 { return responsedto.FlightStepItem{ Phase: "duty_roster", ID: "", Status: "unassigned", } } return responsedto.FlightStepItem{ Phase: "duty_roster", Status: "completed", CompletedAt: row.CreatedAt.UTC().Format(time.RFC3339), CompletedBy: uuidStringOrEmpty(row.CreatedBy), } } func flightReserveAcStep(row *flight.Flight) responsedto.FlightStepItem { if row == nil { return responsedto.FlightStepItem{ Phase: "reserve_ac", ID: "", Status: "unassigned", } } var reserveRow *reserveac.ReserveAc var reserveID []byte if row.Takeover != nil { reserveRow = row.Takeover.ReserveAc reserveID = row.Takeover.ReserveAcID } if len(reserveID) != 16 { return responsedto.FlightStepItem{ Phase: "reserve_ac", ID: "", Status: "unassigned", } } step := responsedto.FlightStepItem{ Phase: "reserve_ac", ID: idString(reserveID), Status: "in_progress", } status := "" if reserveRow != nil { status = reserveRow.Status } switch reserveacutil.NormalizeStatus(status) { case reserveacutil.StatusApproved: step.Status = "completed" if reserveRow != nil && !reserveRow.UpdatedAt.IsZero() { step.CompletedAt = reserveRow.UpdatedAt.UTC().Format(time.RFC3339) } if reserveRow != nil { step.CompletedBy = uuidStringOrEmpty(reserveRow.UpdatedBy) } case reserveacutil.StatusOnProgress: step.Status = "in_progress" case reserveacutil.StatusPending: step.Status = "pending" default: step.Status = "in_progress" } return step } func timeStringOrEmptyRefTime(v *time.Time) string { if v == nil || v.IsZero() { return "" } return v.UTC().Format(time.RFC3339) } func stringPtrOrEmpty(v *string) string { if v == nil { return "" } return strings.TrimSpace(*v) } func parseOptionalUUIDFlight(v *string, pointer string) ([]byte, *jsonapi.ErrorObject) { if v == nil { return nil, nil } raw := strings.TrimSpace(*v) if raw == "" { return nil, nil } id, err := uuidv7.ParseString(raw) if err != nil { return nil, &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: strings.TrimPrefix(pointer, "/data/attributes/") + " is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: pointer}, } } return id, nil } func writeFlightErrors(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 if strings.TrimSpace(e.Code) == "" || strings.TrimSpace(e.ErrorCode) == "" { code, errorCode := inferFlightErrorCode(status, e.Title, e.Detail) if strings.TrimSpace(e.Code) == "" { e.Code = code } if strings.TrimSpace(e.ErrorCode) == "" { e.ErrorCode = errorCode } } enriched = append(enriched, e) } return response.WriteErrors(c, status, enriched) } func inferFlightErrorCode(status int, title, detail string) (string, string) { lowerTitle := strings.ToLower(strings.TrimSpace(title)) lowerDetail := strings.ToLower(strings.TrimSpace(detail)) switch status { case fiber.StatusUnauthorized: return apperrorsx.CodeFlightUnauthorized, apperrorsx.ErrFlightUnauthorized.ErrorCode case fiber.StatusNotFound: return apperrorsx.CodeFlightNotFound, apperrorsx.ErrFlightNotFound.ErrorCode case fiber.StatusUnprocessableEntity: switch { case strings.Contains(lowerDetail, "date must be yyyy-mm-dd"): return apperrorsx.CodeFlightDateInvalid, apperrorsx.ErrFlightDateInvalid.ErrorCode case strings.Contains(lowerDetail, "invalid uuid"): return apperrorsx.CodeFlightInvalidUUID, apperrorsx.ErrFlightInvalidUUID.ErrorCode default: return apperrorsx.CodeFlightValidationFailed, apperrorsx.ErrFlightValidationFailed.ErrorCode } case fiber.StatusBadRequest: switch { case strings.Contains(lowerTitle, "invalid json"): return apperrorsx.CodeFlightInvalidJSON, apperrorsx.ErrFlightInvalidJSON.ErrorCode case strings.Contains(lowerTitle, "create failed"): return apperrorsx.CodeFlightCreateFailed, apperrorsx.ErrFlightCreateFailed.ErrorCode case strings.Contains(lowerTitle, "get failed"): return apperrorsx.CodeFlightGetFailed, apperrorsx.ErrFlightGetFailed.ErrorCode case strings.Contains(lowerTitle, "list failed"): return apperrorsx.CodeFlightListFailed, apperrorsx.ErrFlightListFailed.ErrorCode default: return apperrorsx.CodeFlightInvalidPayload, apperrorsx.ErrFlightInvalidPayload.ErrorCode } default: return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode } }