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

1080 lines
36 KiB
Go

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
}
}