package handlers import ( "context" "errors" "strings" "time" "github.com/gofiber/fiber/v2" "gorm.io/gorm" filemanager "wucher/internal/domain/file_manager" flightdata "wucher/internal/domain/flight_data" "wucher/internal/domain/mission" 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 MissionHandler struct { svc mission.Service flightDataSvc flightdata.Service fileManager filemanager.Service storage fileManagerDownloadURLStorage validate *validators.Validator } func NewMissionHandler(svc mission.Service) *MissionHandler { return &MissionHandler{ svc: svc, validate: validators.New(), } } func (h *MissionHandler) WithFlightDataService(svc flightdata.Service) *MissionHandler { h.flightDataSvc = svc return h } func (h *MissionHandler) WithFileStorage(storage fileManagerDownloadURLStorage) *MissionHandler { h.storage = storage return h } func (h *MissionHandler) WithFileManagerService(svc filemanager.Service) *MissionHandler { h.fileManager = svc return h } func (h *MissionHandler) attachMissionFiles(ctx context.Context, missionID []byte, files []requestdto.MissionFileInput, actorID []byte) { if len(files) == 0 || h.fileManager == nil { return } folderID, err := ensureMissionFileFolderPath(ctx, h.fileManager, missionID, actorID) if err != nil { return } missionRefID := idString(missionID) for i := range files { uploadIntentRaw := strings.TrimSpace(files[i].UploadIntentUUID) if uploadIntentRaw == "" { continue } uploadIntentID, parseErr := uuidv7.ParseString(uploadIntentRaw) if parseErr != nil { continue } name := strings.TrimSpace(files[i].Name) created, createErr := h.fileManager.CreateFileNodeFromUploadIntent(ctx, filemanager.CreateFileFromUploadIntentParams{ UploadIntentID: uploadIntentID, FolderID: folderID, FolderProvided: true, Name: name, ActorID: actorID, }) if createErr != nil { if !errors.Is(createErr, filemanager.ErrDuplicateName) { continue } existing, exErr := h.fileManager.GetFileByFolderAndName(ctx, folderID, strings.ToLower(name)) if exErr != nil || existing == nil { continue } created = existing } if created == nil { continue } attachParams := filemanager.CreateAttachmentParams{ FileID: created.ID, RefType: "mission", RefID: missionRefID, SortOrder: 0, IsPrimary: false, ActorID: actorID, } // The file finishes processing (→ ready) asynchronously, shortly after // the upload intent is finalized above. CreateAttachment rejects a // not-yet-ready file, so wait briefly for readiness instead of silently // dropping the attachment (bounded ~10s; processing is normally ~1-2s). var attachment *filemanager.Attachment var attachErr error for attempt := 0; attempt < 40; attempt++ { attachment, attachErr = h.fileManager.CreateAttachment(ctx, attachParams) if attachErr == nil || !errors.Is(attachErr, filemanager.ErrFileNotReadyForAttachment) { break } if ctx.Err() != nil { break } time.Sleep(250 * time.Millisecond) } if attachErr != nil || attachment == nil { continue } _ = h.svc.AttachFile(ctx, missionID, attachment.ID) } } // CreateMission godoc // @Summary Create mission // @Description Create a mission and link it to a flight. // @Description `type` must be one of: `HEMS`, `CAT`, `SPO`, `NCO`. // @Description `subtype_id` is required when `type=HEMS` and must reference mission subcategory under `HEMS` category. // @Description `subtype_id` must be empty when `type` is not `HEMS`. // @Tags Flights - Mission // @Accept json // @Produce json // @Param request body requestdto.MissionCreateRequest true "JSON:API mission create request" // @Success 201 {object} responsedto.MissionResponse // @Failure 422 {object} jsonapi.ErrorDocument // @Failure 409 {object} jsonapi.ErrorDocument // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/mission/create [post] func (h *MissionHandler) Create(c *fiber.Ctx) error { var req requestdto.MissionCreateRequest if err := c.BodyParser(&req); err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidJSON, err.Error(), nil) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeMissionValidationErrors(c, errs) } flightID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.FlightID)) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidFlightUUID, mission.ErrorMessageInvalidFlightID, &jsonapi.ErrorSource{Pointer: "/data/attributes/flight_id"}) } var subtypeID []byte subtypeRaw := strings.TrimSpace(req.Data.Attributes.SubtypeID) if subtypeRaw != "" { subtypeID, err = uuidv7.ParseString(subtypeRaw) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionSubtypeInvalid, mission.ErrorMessageSubtypeInvalid, &jsonapi.ErrorSource{Pointer: "/data/attributes/subtype_id"}) } } row := &mission.Mission{ FlightID: flightID, Type: strings.ToUpper(strings.TrimSpace(req.Data.Attributes.Type)), SubtypeID: subtypeID, Note: strings.TrimSpace(req.Data.Attributes.Note), CreatedBy: actorUserID(c), UpdatedBy: actorUserID(c), } if err := h.svc.Create(c.UserContext(), row); err != nil { return writeMissionServiceError(c, err, "/data/attributes/flight_id") } h.attachMissionFiles(c.UserContext(), row.ID, req.Data.Attributes.Files, actorUserID(c)) if created, getErr := h.svc.GetByID(c.UserContext(), row.ID); getErr == nil && created != nil { row = created } doc, err := h.missionDocument(c.UserContext(), row) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionCreateFailed, err.Error(), nil) } return response.Write(c, fiber.StatusCreated, doc) } // CreateMissionType godoc // @Summary Create mission type // @Description Create a mission category master record for use in mission flow. // @Tags Flights - Mission // @Accept json // @Produce json // @Param request body requestdto.MissionTypeCreateRequest true "JSON:API mission type create request" // @Success 201 {object} responsedto.MissionTypeResource // @Failure 422 {object} jsonapi.ErrorDocument // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/mission-type/create [post] func (h *MissionHandler) CreateType(c *fiber.Ctx) error { var req requestdto.MissionTypeCreateRequest if err := c.BodyParser(&req); err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidJSON, err.Error(), nil) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeMissionValidationErrors(c, errs) } row := &mission.MissionCategory{ CodeType: strings.ToUpper(strings.TrimSpace(req.Data.Attributes.CodeType)), TypeName: strings.TrimSpace(req.Data.Attributes.TypeName), } if err := h.svc.CreateType(c.UserContext(), row); err != nil { return writeMissionServiceError(c, err, "/data/attributes/code_type") } return response.Write(c, fiber.StatusCreated, missionTypeResource(row)) } // UpdateMission godoc // @Summary Update mission // @Description Update mission type/subtype/note by mission ID, and optionally attach files already uploaded via `upload-mission-file`. // @Description `type` must be one of: `HEMS`, `CAT`, `SPO`, `NCO`. // @Description `subtype_id` is required when `type=HEMS` and must reference mission subcategory under `HEMS` category. // @Description `subtype_id` must be empty when `type` is not `HEMS`. // @Description `files[]` items reference an `upload_intent_uuid` (from `upload-mission-file`) and an optional display `name`. // @Tags Flights - Mission // @Accept json // @Produce json // @Param mission_id path string true "Mission ID (UUIDv7)" // @Param request body requestdto.MissionUpdateRequest true "JSON:API mission update request" // @Success 200 {object} responsedto.MissionResponse // @Failure 422 {object} jsonapi.ErrorDocument // @Failure 404 {object} jsonapi.ErrorDocument // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/mission/update/{mission_id} [patch] func (h *MissionHandler) Update(c *fiber.Ctx) error { missionRaw := strings.TrimSpace(c.Params("mission_id")) missionID, err := uuidv7.ParseString(missionRaw) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidMissionUUID, mission.ErrorMessageInvalidMissionID, &jsonapi.ErrorSource{Pointer: "/path/mission_id"}) } var req requestdto.MissionUpdateRequest if err := c.BodyParser(&req); err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidJSON, err.Error(), nil) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeMissionValidationErrors(c, errs) } var subtypeID []byte subtypeRaw := strings.TrimSpace(req.Data.Attributes.SubtypeID) if subtypeRaw != "" { subtypeID, err = uuidv7.ParseString(subtypeRaw) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionSubtypeInvalid, mission.ErrorMessageSubtypeInvalid, &jsonapi.ErrorSource{Pointer: "/data/attributes/subtype_id"}) } } if err := h.svc.UpdateByID( c.UserContext(), missionID, strings.ToUpper(strings.TrimSpace(req.Data.Attributes.Type)), subtypeID, strings.TrimSpace(req.Data.Attributes.Note), actorUserID(c), ); err != nil { return writeMissionServiceError(c, err, "/path/mission_id") } h.attachMissionFiles(c.UserContext(), missionID, req.Data.Attributes.Files, actorUserID(c)) row, err := h.svc.GetByID(c.UserContext(), missionID) if err != nil { return writeMissionServiceError(c, err, "/path/mission_id") } if row == nil { return writeMissionError(c, apperrorsx.ErrMissionNotFound, mission.ErrorMessageNotFound, nil) } doc, buildErr := h.missionDocument(c.UserContext(), row) if buildErr != nil { return writeMissionError(c, apperrorsx.ErrMissionUpdateFailed, buildErr.Error(), nil) } return response.Write(c, fiber.StatusOK, doc) } // RemoveFile godoc // @Summary Remove a file from a mission // @Description Detaches the file (by its attachment id) from the mission and deletes the attachment. // @Tags Flights - Mission // @Produce json // @Param mission_id path string true "Mission UUID" // @Param attachment_id path string true "Attachment UUID (the file's attachment_id)" // @Success 200 {object} responsedto.MissionResponse // @Failure 404 {object} jsonapi.ErrorDocument // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/mission/file/{mission_id}/{attachment_id} [delete] func (h *MissionHandler) RemoveFile(c *fiber.Ctx) error { missionID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("mission_id"))) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidMissionUUID, mission.ErrorMessageInvalidMissionID, &jsonapi.ErrorSource{Pointer: "/path/mission_id"}) } attachmentID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("attachment_id"))) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidMissionUUID, "invalid attachment id", &jsonapi.ErrorSource{Pointer: "/path/attachment_id"}) } if err := h.svc.DetachFile(c.UserContext(), missionID, attachmentID); err != nil { return writeMissionServiceError(c, err, "/path/attachment_id") } if h.fileManager != nil { // Best-effort: the junction row is already gone; an orphaned attachment // is harmless and is cleaned up by lifecycle jobs. _ = h.fileManager.DeleteAttachment(c.UserContext(), attachmentID) } row, err := h.svc.GetByID(c.UserContext(), missionID) if err != nil { return writeMissionServiceError(c, err, "/path/mission_id") } if row == nil { return writeMissionError(c, apperrorsx.ErrMissionNotFound, mission.ErrorMessageNotFound, nil) } doc, buildErr := h.missionDocument(c.UserContext(), row) if buildErr != nil { return writeMissionError(c, apperrorsx.ErrMissionUpdateFailed, buildErr.Error(), nil) } return response.Write(c, fiber.StatusOK, doc) } // DeleteMission godoc // @Summary Delete mission // @Description Soft delete mission by linked flight ID. // @Tags Flights - Mission // @Produce json // @Param flight_id path string true "Flight ID (UUIDv7)" // @Success 200 {object} dto.GenericDeleteResponse // @Failure 422 {object} jsonapi.ErrorDocument // @Failure 404 {object} jsonapi.ErrorDocument // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/mission/delete/{flight_id} [delete] func (h *MissionHandler) Delete(c *fiber.Ctx) error { flightRaw := strings.TrimSpace(c.Params("flight_id")) flightID, err := uuidv7.ParseString(flightRaw) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidFlightUUID, mission.ErrorMessageInvalidFlightID, &jsonapi.ErrorSource{Pointer: "/path/flight_id"}) } if err := h.svc.DeleteByFlightID(c.UserContext(), flightID, actorUserID(c)); err != nil { return writeMissionServiceError(c, err, "/path/flight_id") } return response.Write(c, fiber.StatusOK, dto.NewGenericDeleteResponse("mission_delete")) } // DeleteByIDMission godoc // @Summary Delete mission by mission ID // @Description Soft delete mission by its mission UUID. This also soft deletes flight data rows linked to the mission. // @Tags Flights - Mission // @Produce json // @Param mission_id path string true "Mission ID (UUIDv7)" // @Success 200 {object} dto.GenericDeleteResponse // @Failure 422 {object} jsonapi.ErrorDocument // @Failure 404 {object} jsonapi.ErrorDocument // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/mission/delete-by-id/{mission_id} [delete] func (h *MissionHandler) DeleteByID(c *fiber.Ctx) error { missionRaw := strings.TrimSpace(c.Params("mission_id")) missionID, err := uuidv7.ParseString(missionRaw) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidMissionUUID, mission.ErrorMessageInvalidMissionID, &jsonapi.ErrorSource{Pointer: "/path/mission_id"}) } if err := h.svc.DeleteByID(c.UserContext(), missionID, actorUserID(c)); err != nil { return writeMissionServiceError(c, err, "/path/mission_id") } return response.Write(c, fiber.StatusOK, dto.NewGenericDeleteResponse("mission_delete")) } // GetMissionByFlightID godoc // @Summary Get mission by flight ID // @Description Get mission detail by linked flight ID. // @Tags Flights - Mission // @Produce json // @Param flight_id path string true "Flight ID (UUIDv7)" // @Success 200 {object} responsedto.MissionResponse // @Failure 422 {object} jsonapi.ErrorDocument // @Failure 404 {object} jsonapi.ErrorDocument // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/mission/get/{flight_id} [get] func (h *MissionHandler) GetByFlightID(c *fiber.Ctx) error { flightRaw := strings.TrimSpace(c.Params("flight_id")) if flightRaw == "" { flightRaw = strings.TrimSpace(c.Params("flight_uuid")) } flightID, err := uuidv7.ParseString(flightRaw) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidFlightUUID, mission.ErrorMessageInvalidFlight, &jsonapi.ErrorSource{Pointer: "/path/flight_id"}) } rows, err := h.svc.ListByFlightID(c.UserContext(), flightID) if err != nil { return writeMissionServiceError(c, err, "/path/flight_id") } if len(rows) == 0 { return writeMissionError(c, apperrorsx.ErrMissionNotFound, mission.ErrorMessageNotFound, nil) } doc, buildErr := h.missionDocuments(c.UserContext(), rows) if buildErr != nil { return writeMissionError(c, apperrorsx.ErrMissionGetFailed, buildErr.Error(), nil) } return response.Write(c, fiber.StatusOK, doc) } // ListMissions godoc // @Summary List missions // @Description Supports `search`, `filter[search]`, `flight_id`, `sort`, `page[number]`, `page[size]`, `page`, `size`, and `limit`. // @Tags Flights - Mission // @Produce json // @Param search query string false "Search text" // @Param filter[search] query string false "Search text (JSON:API style)" // @Param flight_id query string false "Filter by flight UUID" // @Param flight_data_status query string false "Filter missions by flight data status: draft, in_progress, or completed" // @Param sort query string false "Sort field: type|-type|flight_date|-flight_date|created_at|-created_at|updated_at|-updated_at" // @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.MissionListResponse // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/mission/get-all [get] func (h *MissionHandler) List(c *fiber.Ctx) error { filter := c.Query("filter[search]") if filter == "" { filter = c.Query("search") } flightQuery := strings.TrimSpace(c.Query("flight_id")) var flightID []byte if flightQuery != "" { parsedFlightID, err := uuidv7.ParseString(flightQuery) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidFlightUUID, mission.ErrorMessageInvalidFlightID, &jsonapi.ErrorSource{Pointer: "/query/flight_id"}) } flightID = parsedFlightID } flightDataStatus := strings.TrimSpace(c.Query("flight_data_status")) if flightDataStatus != "" && flightDataStatus != mission.StatusDraft && flightDataStatus != mission.StatusInProgress && flightDataStatus != mission.StatusCompleted { return writeMissionError(c, apperrorsx.ErrMissionInvalidPayload, "flight_data_status must be one of: draft, in_progress, completed", &jsonapi.ErrorSource{Pointer: "/query/flight_data_status"}) } 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"), sharedconst.MissionDefaultPageSize) } if pageSize > sharedconst.MissionMaxPageSize { pageSize = sharedconst.MissionMaxPageSize } if pageSize < 1 { pageSize = sharedconst.MissionDefaultPageSize } if pageNumber < 1 { pageNumber = 1 } offset := (pageNumber - 1) * pageSize _ = offset sort := c.Query("sort") rows, total, err := h.svc.List(c.UserContext(), filter, sort, flightID, flightDataStatus, 0, 0) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionListFailed, err.Error(), nil) } data := make([]responsedto.MissionResource, 0, len(rows)) for i := range rows { resource, buildErr := h.missionResource(c.UserContext(), &rows[i]) if buildErr != nil { return writeMissionError(c, apperrorsx.ErrMissionListFailed, buildErr.Error(), nil) } data = append(data, resource) } return response.WriteWithMeta(c, fiber.StatusOK, data, map[string]any{ "page_number": pageNumber, "page_size": pageSize, "total": total, }) } // ListMissionDatatable godoc // @Summary List missions (datatable) // @Description Supports `search`, `filter[search]`, `date_from`, `date_to`, `mission_type`, `pilot_id`, `helicopter_id`, `sort`, `start`, `length`, `draw`, `page[number]`, `page[size]`, `page`, `size`, and `limit`. // @Description `date_from` and `date_to` filter flights by their mission date range (`YYYY-MM-DD`). // @Description `mission_type` accepts `HEMS`, `CAT`, `SPO`, or `NCO`. // @Description `pilot_id` filters missions where the pilot is assigned on the linked flight roster. // @Description `helicopter_id` filters missions by the helicopter used in the linked flight takeover. // @Tags Flights - Mission // @Produce json // @Param search query string false "Search text" // @Param filter[search] query string false "Search text (JSON:API style)" // @Param flight_id query string false "Filter by linked flight UUID" // @Param date_from query string false "Mission date lower bound (YYYY-MM-DD)" // @Param date_to query string false "Mission date upper bound (YYYY-MM-DD)" // @Param mission_type query string false "Mission type: HEMS|CAT|SPO|NCO" // @Param pilot_id query string false "Pilot user UUID" // @Param helicopter_id query string false "Helicopter UUID" // @Param sort query string false "Sort field: type|-type|flight_date|-flight_date|created_at|-created_at|updated_at|-updated_at" // @Param start query int false "DataTables start index" // @Param length query int false "DataTables page size" // @Param draw query int false "DataTables draw counter" // @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.MissionDataTableResponse // @Failure 422 {object} jsonapi.ErrorDocument // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/mission/get-all/dt [get] func (h *MissionHandler) ListDatatable(c *fiber.Ctx) error { filter, errObj := missionListFilterFromQuery(c) if errObj != nil { return writeMissionError(c, apperrorsx.ErrMissionInvalidPayload, errObj.Detail, errObj.Source) } draw := parseIntDefault(c.Query("draw"), 1) 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"), sharedconst.MissionDefaultPageSize) } if pageSize > sharedconst.MissionMaxPageSize { pageSize = sharedconst.MissionMaxPageSize } if pageSize < 1 { pageSize = sharedconst.MissionDefaultPageSize } if pageNumber < 1 { pageNumber = 1 } if start := parseIntDefault(c.Query("start"), -1); start >= 0 && c.Query("length") != "" { length := parseIntDefault(c.Query("length"), pageSize) if length < 1 { length = sharedconst.MissionDefaultPageSize } if length > sharedconst.MissionMaxPageSize { length = sharedconst.MissionMaxPageSize } pageSize = length pageNumber = (start / pageSize) + 1 } offset := (pageNumber - 1) * pageSize rows, total, filtered, err := h.svc.ListDatatable(c.UserContext(), filter, pageSize, offset) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionListFailed, err.Error(), nil) } data := make([]responsedto.MissionResource, 0, len(rows)) for i := range rows { resource, buildErr := h.missionResource(c.UserContext(), &rows[i]) if buildErr != nil { return writeMissionError(c, apperrorsx.ErrMissionListFailed, buildErr.Error(), nil) } data = append(data, resource) } return response.WriteWithMeta(c, fiber.StatusOK, data, map[string]any{ "draw": draw, "records_total": total, "records_filtered": filtered, }) } // ListMissionTypes godoc // @Summary List mission types with subtypes // @Description Returns all mission categories and their available subtypes. // @Tags Flights - Mission // @Produce json // @Success 200 {object} responsedto.MissionTypeListResponse // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/mission-type/get-all [get] func (h *MissionHandler) ListTypes(c *fiber.Ctx) error { rows, err := h.svc.ListTypes(c.UserContext()) if err != nil { return writeMissionError(c, apperrorsx.ErrMissionListFailed, err.Error(), nil) } data := make([]responsedto.MissionTypeResource, 0, len(rows)) for i := range rows { data = append(data, missionTypeResource(&rows[i])) } return response.Write(c, fiber.StatusOK, data) } func missionListFilterFromQuery(c *fiber.Ctx) (mission.ListFilter, *jsonapi.ErrorObject) { q := func(keys ...string) string { for _, key := range keys { if value := strings.TrimSpace(c.Query(key)); value != "" { return value } } return "" } filter := mission.ListFilter{ Search: q("filter[search]", "search"), Sort: strings.TrimSpace(c.Query("sort")), } if flightRaw := q("flight_id"); flightRaw != "" { flightID, err := uuidv7.ParseString(flightRaw) if err != nil { return filter, &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: "flight_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/query/flight_id"}, } } filter.FlightID = flightID } if dateFromRaw := q("date_from", "start_date", "from"); dateFromRaw != "" { dateFrom, err := time.Parse("2006-01-02", dateFromRaw) if err != nil { return filter, &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: "date_from must be YYYY-MM-DD", Source: &jsonapi.ErrorSource{Pointer: "/query/date_from"}, } } filter.StartDate = &dateFrom } if dateToRaw := q("date_to", "end_date", "to"); dateToRaw != "" { dateTo, err := time.Parse("2006-01-02", dateToRaw) if err != nil { return filter, &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: "date_to must be YYYY-MM-DD", Source: &jsonapi.ErrorSource{Pointer: "/query/date_to"}, } } filter.EndDate = &dateTo } if filter.StartDate != nil && filter.EndDate != nil && filter.EndDate.Before(*filter.StartDate) { return filter, &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: "date_to must be >= date_from", Source: &jsonapi.ErrorSource{Pointer: "/query/date_to"}, } } filter.MissionType = strings.ToUpper(strings.TrimSpace(q("mission_type", "type"))) switch filter.MissionType { case "", sharedconst.MissionTypeHEMS, sharedconst.MissionTypeCAT, sharedconst.MissionTypeSPO, sharedconst.MissionTypeNCO: default: return filter, &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: "mission_type must be one of: HEMS, CAT, SPO, NCO", Source: &jsonapi.ErrorSource{Pointer: "/query/mission_type"}, } } if pilotRaw := q("pilot_id"); pilotRaw != "" { pilotID, err := uuidv7.ParseString(pilotRaw) if err != nil { return filter, &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: "pilot_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/query/pilot_id"}, } } filter.PilotID = pilotID } if heliRaw := q("helicopter_id"); heliRaw != "" { helicopterID, err := uuidv7.ParseString(heliRaw) if err != nil { return filter, &jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: "helicopter_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/query/helicopter_id"}, } } filter.HelicopterID = helicopterID } return filter, nil } func (h *MissionHandler) missionDocument(ctx context.Context, row *mission.Mission) (responsedto.MissionResponse, error) { resource, err := h.missionResource(ctx, row) if err != nil { return responsedto.MissionResponse{}, err } return responsedto.MissionResponse{Data: resource}, nil } func (h *MissionHandler) missionDocuments(ctx context.Context, rows []mission.Mission) (responsedto.MissionListResponse, error) { data := make([]responsedto.MissionResource, 0, len(rows)) for i := range rows { resource, err := h.missionResource(ctx, &rows[i]) if err != nil { return responsedto.MissionListResponse{}, err } data = append(data, resource) } return responsedto.MissionListResponse{Data: data}, nil } func (h *MissionHandler) missionResource(ctx context.Context, row *mission.Mission) (responsedto.MissionResource, error) { missionType := strings.ToUpper(strings.TrimSpace(row.Type)) flightDataRows, flightDataResources, err := h.missionFlightDataResources(ctx, row) if err != nil { return responsedto.MissionResource{}, err } flightDataStatus := mission.StatusDraft if len(flightDataRows) > 0 { flightDataStatus = mission.StatusInProgress allComplete := true for i := range flightDataRows { if !flightdata.IsComplete(&flightDataRows[i]) { allComplete = false break } } if allComplete { flightDataStatus = mission.StatusCompleted } } formStates := mission.BuildForms(missionType, mission.FormFacts{ mission.FormKeyFlightData: flightDataStatus, }) formData := &responsedto.MissionForms{} if len(flightDataResources) > 0 { formData.FlightData = flightDataResources } return responsedto.MissionResource{ Type: sharedconst.MissionResourceType, ID: idString(row.ID), Attributes: responsedto.MissionAttributes{ Flight: missionFlightRef(row), Type: missionType, MissionCode: strings.TrimSpace(row.Code), Subtypes: missionSubtypeRef(row), Note: strings.TrimSpace(row.Note), StartTime: normalizeMissionClockDisplay(row.StartTime), EndTime: normalizeMissionClockDisplay(row.EndTime), Status: mission.ComputeStatus(formStates), Forms: formData, Files: h.missionFiles(ctx, row), CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), CreatedBy: uuidStringOrEmpty(row.CreatedBy), UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339), UpdatedBy: uuidStringOrEmpty(row.UpdatedBy), }, }, nil } func (h *MissionHandler) missionFiles(ctx context.Context, row *mission.Mission) []responsedto.MissionFileItem { if row == nil || len(row.Files) == 0 { return nil } files := make([]responsedto.MissionFileItem, 0, len(row.Files)) for i := range row.Files { f := &row.Files[i] item := responsedto.MissionFileItem{ ID: idString(f.FileAttachmentID), AttachmentID: idString(f.FileAttachmentID), } if f.FileAttachment != nil && f.FileAttachment.File != nil { // Expose the underlying file uuid as the item id so it matches the // `file_id` carried by `file.updated` SSE events — letting the // frontend resolve per-file processing readiness in real time. item.ID = idString(f.FileAttachment.File.ID) item.FileName = strings.TrimSpace(f.FileAttachment.File.Name) urls := resolveFileManagerDownloadURLs(ctx, h.storage, f.FileAttachment.File) item.DownloadURL = urls.OriginalURL item.ThumbnailURL = urls.ThumbnailURL } files = append(files, item) } return files } func missionSubtypeRef(row *mission.Mission) *responsedto.MissionSubtypeRef { if row == nil || len(row.SubtypeID) == 0 { return nil } subtype := "" subtypeName := "" if row.MissionSubCategory != nil { subtype = row.MissionSubCategory.SubCodeType subtypeName = row.MissionSubCategory.SubTypeName } return &responsedto.MissionSubtypeRef{ ID: idString(row.SubtypeID), Subtype: strings.TrimSpace(subtype), SubtypeName: strings.TrimSpace(subtypeName), } } func normalizeMissionClockDisplay(raw string) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } if len(trimmed) >= 5 { return trimmed[:5] } return trimmed } func missionFlightRef(row *mission.Mission) *responsedto.MissionFlightRef { if row == nil { return nil } if row.Flight == nil && len(row.FlightID) == 0 { return nil } out := &responsedto.MissionFlightRef{ID: idString(row.FlightID)} if row.Flight != nil { out.ID = idString(row.Flight.ID) out.Code = strings.TrimSpace(stringPtrOrEmpty(row.Flight.MissionCode)) if !row.Flight.Date.IsZero() { out.Date = row.Flight.Date.UTC().Format("2006-01-02") } } return out } func missionTypeResource(row *mission.MissionCategory) responsedto.MissionTypeResource { subtypes := make([]responsedto.MissionTypeSubtypeItem, 0, len(row.SubCategory)) for i := range row.SubCategory { subtypes = append(subtypes, responsedto.MissionTypeSubtypeItem{ ID: idString(row.SubCategory[i].ID), Code: strings.TrimSpace(row.SubCategory[i].SubCodeType), Name: strings.TrimSpace(row.SubCategory[i].SubTypeName), TaskName: strings.TrimSpace(row.SubCategory[i].TaskName), }) } return responsedto.MissionTypeResource{ Type: "mission_type", ID: idString(row.ID), Attributes: responsedto.MissionTypeAttributes{ Code: strings.TrimSpace(row.CodeType), Name: strings.TrimSpace(row.TypeName), Subtypes: subtypes, }, } } func (h *MissionHandler) missionFlightDataResources(ctx context.Context, row *mission.Mission) ([]flightdata.FlightData, []responsedto.FlightDataResource, error) { if h == nil || h.flightDataSvc == nil || row == nil || len(row.ID) != 16 { return make([]flightdata.FlightData, 0), make([]responsedto.FlightDataResource, 0), nil } rows, err := h.flightDataSvc.ListByMissionID(ctx, row.ID) if err != nil { return nil, nil, err } if len(rows) == 0 { return make([]flightdata.FlightData, 0), make([]responsedto.FlightDataResource, 0), nil } flightDataResources := make([]responsedto.FlightDataResource, 0, len(rows)) for i := range rows { resource, err := buildFlightDataResourceWithService(ctx, h.flightDataSvc, &rows[i]) if err != nil { return nil, nil, err } flightDataResources = append(flightDataResources, resource) } return rows, flightDataResources, nil } func writeMissionError(c *fiber.Ctx, def apperrorsx.Def, detail string, source *jsonapi.ErrorSource) error { return response.WriteErrors(c, def.Status, []jsonapi.ErrorObject{{ Code: def.Code, Detail: detail, Source: source, }}) } func writeMissionValidationErrors(c *fiber.Ctx, errs []jsonapi.ErrorObject) error { for i := range errs { if errs[i].Code == "" { errs[i].Code = apperrorsx.CodeMissionInvalidPayload } } return response.WriteErrors(c, fiber.StatusUnprocessableEntity, errs) } func writeMissionServiceError(c *fiber.Ctx, err error, pointer string) error { switch { case errors.Is(err, mission.ErrTypeInvalid): return writeMissionError(c, apperrorsx.ErrMissionTypeInvalid, mission.ErrorMessageTypeInvalid, &jsonapi.ErrorSource{Pointer: "/data/attributes/type"}) case errors.Is(err, mission.ErrFlightIDRequired): return writeMissionError(c, apperrorsx.ErrMissionFlightIDRequired, mission.ErrorMessageFlightIDRequired, &jsonapi.ErrorSource{Pointer: pointer}) case errors.Is(err, mission.ErrMissionIDRequired): return writeMissionError(c, apperrorsx.ErrMissionMissionIDRequired, mission.ErrorMessageMissionIDRequired, &jsonapi.ErrorSource{Pointer: pointer}) case errors.Is(err, mission.ErrSubtypeRequired): return writeMissionError(c, apperrorsx.ErrMissionSubtypeRequired, mission.ErrorMessageSubtypeRequired, &jsonapi.ErrorSource{Pointer: "/data/attributes/subtype_id"}) case errors.Is(err, mission.ErrSubtypeInvalid): return writeMissionError(c, apperrorsx.ErrMissionSubtypeInvalid, mission.ErrorMessageSubtypeInvalid, &jsonapi.ErrorSource{Pointer: "/data/attributes/subtype_id"}) case errors.Is(err, mission.ErrSubtypeForbidden): return writeMissionError(c, apperrorsx.ErrMissionSubtypeForbidden, mission.ErrorMessageSubtypeForbidden, &jsonapi.ErrorSource{Pointer: "/data/attributes/subtype_id"}) case errors.Is(err, mission.ErrRequired): return writeMissionError(c, apperrorsx.ErrMissionCreateFailed, mission.ErrorMessageRequired, nil) case errors.Is(err, mission.ErrDuplicateFlight): return writeMissionError(c, apperrorsx.ErrMissionDuplicateFlight, mission.ErrorMessageDuplicateFlight, &jsonapi.ErrorSource{Pointer: "/data/attributes/flight_id"}) case errors.Is(err, mission.ErrLockedAfterFlightInspection): return writeMissionError(c, apperrorsx.ErrMissionLockedAfterFlightInspection, mission.ErrorMessageLockedAfterFlightInspection, &jsonapi.ErrorSource{Pointer: pointer}) case errors.Is(err, mission.ErrScheduleMissing): return writeMissionError(c, apperrorsx.ErrMissionInvalidPayload, mission.ErrorMessageScheduleMissing, &jsonapi.ErrorSource{Pointer: pointer}) case errors.Is(err, mission.ErrBaseMissing): return writeMissionError(c, apperrorsx.ErrMissionInvalidPayload, mission.ErrorMessageBaseMissing, &jsonapi.ErrorSource{Pointer: pointer}) case errors.Is(err, mission.ErrTwilightMissing): return writeMissionError(c, apperrorsx.ErrMissionInvalidPayload, mission.ErrorMessageTwilightMissing, &jsonapi.ErrorSource{Pointer: pointer}) case errors.Is(err, mission.ErrCoordinatesInvalid): return writeMissionError(c, apperrorsx.ErrMissionInvalidPayload, mission.ErrorMessageCoordinatesInvalid, &jsonapi.ErrorSource{Pointer: pointer}) case errors.Is(err, gorm.ErrRecordNotFound): return writeMissionError(c, apperrorsx.ErrMissionNotFound, mission.ErrorMessageNotFound, &jsonapi.ErrorSource{Pointer: pointer}) default: return writeMissionError(c, apperrorsx.ErrMissionDeleteFailed, err.Error(), nil) } }