957 lines
36 KiB
Go
957 lines
36 KiB
Go
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)
|
|
}
|
|
}
|