init push

This commit is contained in:
2026-07-16 22:16:45 +07:00
commit 8b068bdb10
1021 changed files with 332816 additions and 0 deletions

View File

@@ -0,0 +1,956 @@
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)
}
}