635 lines
25 KiB
Go
635 lines
25 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"gorm.io/gorm"
|
|
|
|
complaint "wucher/internal/domain/complaint"
|
|
easarelease "wucher/internal/domain/easa_release"
|
|
fleetstatus "wucher/internal/domain/fleet_status"
|
|
"wucher/internal/domain/helicopter"
|
|
"wucher/internal/domain/mcf"
|
|
"wucher/internal/shared/pkg/apperrorsx"
|
|
"wucher/internal/shared/pkg/userctx"
|
|
"wucher/internal/shared/pkg/uuidv7"
|
|
requestdto "wucher/internal/transport/http/dto/request"
|
|
responsedto "wucher/internal/transport/http/dto/response"
|
|
"wucher/internal/transport/http/jsonapi"
|
|
"wucher/internal/transport/http/response"
|
|
"wucher/internal/transport/http/validators"
|
|
)
|
|
|
|
type FleetStatusHandler struct {
|
|
svc fleetstatus.Service
|
|
helicopterSvc helicopter.Service
|
|
complaintSvc complaintLister
|
|
easaSvc easaReleaseLister
|
|
historySvc fleetHistoryLister
|
|
mcfSvc fleetMCFService
|
|
signoffSvc complaintSignoffLookup
|
|
storage fileManagerDownloadURLStorage
|
|
validate *validators.Validator
|
|
}
|
|
|
|
type FleetStatusDeps struct {
|
|
Helicopter helicopter.Service
|
|
Complaint complaintLister
|
|
EASA easaReleaseLister
|
|
History fleetHistoryLister
|
|
MCF fleetMCFService
|
|
Signoff complaintSignoffLookup
|
|
Storage fileManagerDownloadURLStorage
|
|
}
|
|
type complaintLister interface {
|
|
ListByHelicopter(ctx context.Context, helicopterID []byte, limit, offset int) ([]complaint.Complaint, int64, error)
|
|
ResolveUserNames(ctx context.Context, complaints []complaint.Complaint) (map[string]string, error)
|
|
ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error)
|
|
OpenByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error)
|
|
}
|
|
|
|
// fleetMCFService is the MCF access the fleet page needs: pending flags (for status)
|
|
// plus the latest MCF per helicopter (for the mcf object / placeholder).
|
|
type fleetMCFService interface {
|
|
mcfPendingChecker
|
|
LatestByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]*mcf.MaintenanceCheckFlight, error)
|
|
}
|
|
|
|
type easaReleaseLister interface {
|
|
GetLatestByHelicopter(ctx context.Context, helicopterID []byte) (*easarelease.EASARelease, error)
|
|
}
|
|
|
|
func NewFleetStatusHandler(svc fleetstatus.Service, deps FleetStatusDeps) *FleetStatusHandler {
|
|
if svc == nil || deps.Helicopter == nil || deps.Complaint == nil || deps.EASA == nil ||
|
|
deps.History == nil || deps.MCF == nil || deps.Storage == nil {
|
|
panic("fleet status handler: missing required dependency")
|
|
}
|
|
return &FleetStatusHandler{
|
|
svc: svc,
|
|
helicopterSvc: deps.Helicopter,
|
|
complaintSvc: deps.Complaint,
|
|
easaSvc: deps.EASA,
|
|
historySvc: deps.History,
|
|
mcfSvc: deps.MCF,
|
|
signoffSvc: deps.Signoff,
|
|
storage: deps.Storage,
|
|
validate: validators.New(),
|
|
}
|
|
}
|
|
|
|
// CreateFleetStatus godoc
|
|
// @Summary Create fleet status
|
|
// @Description Create fleet status with maintenance schedules and file attachments.
|
|
// @Tags Fleet Status
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body requestdto.FleetStatusCreateRequest true "Fleet status create request"
|
|
// @Success 201 {object} responsedto.FleetStatusResponse
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Failure 422 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/fleet-status/create [post]
|
|
func (h *FleetStatusHandler) Create(c *fiber.Ctx) error {
|
|
var req requestdto.FleetStatusCreateRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusInvalidJSON, "", nil)
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeFleetStatusValidationErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
row, err := mapFleetStatusPayload(req.Data.Attributes, actorUserID(c))
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusInvalidPayload, "", nil)
|
|
}
|
|
if err := h.svc.Create(c.UserContext(), row); err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.Create", err)
|
|
}
|
|
created, err := h.svc.GetByID(c.UserContext(), row.ID)
|
|
if err != nil {
|
|
slog.WarnContext(c.UserContext(), "fleet status re-fetch after create failed", slog.Any("error", err), slog.String("id", idString(row.ID)))
|
|
}
|
|
if created == nil {
|
|
created = row
|
|
}
|
|
createdStatus := h.deriveHelicopterStatus(c.UserContext(), created.Helicopter, created.HelicopterID)
|
|
return response.Write(c, fiber.StatusCreated, fleetStatusDetailResource(c.UserContext(), h, created, createdStatus))
|
|
}
|
|
|
|
// UpdateFleetStatus godoc
|
|
// @Summary Update fleet status
|
|
// @Description Update fleet status by ID with full replacement for schedules and files.
|
|
// @Tags Fleet Status
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Fleet status ID (UUIDv7)"
|
|
// @Param request body requestdto.FleetStatusUpdateRequest true "Fleet status update request"
|
|
// @Success 200 {object} responsedto.FleetStatusResponse
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Failure 404 {object} jsonapi.ErrorDocument
|
|
// @Failure 422 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/fleet-status/update/{id} [patch]
|
|
func (h *FleetStatusHandler) Update(c *fiber.Ctx) error {
|
|
idStr := c.Params("id")
|
|
id, err := uuidv7.ParseString(idStr)
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusInvalidID, "", &jsonapi.ErrorSource{Pointer: "/path/id"})
|
|
}
|
|
var req requestdto.FleetStatusUpdateRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusInvalidJSON, "", nil)
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeFleetStatusValidationErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
if strings.TrimSpace(req.Data.ID) == "" {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusIDRequired, "", &jsonapi.ErrorSource{Pointer: "/data/id"})
|
|
}
|
|
if strings.TrimSpace(req.Data.ID) != idStr {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusIDMismatch, "", &jsonapi.ErrorSource{Pointer: "/data/id"})
|
|
}
|
|
existing, err := h.svc.GetByID(c.UserContext(), id)
|
|
if err != nil || existing == nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusNotFound, "", nil)
|
|
}
|
|
row, err := mergeFleetStatusPatch(existing, req.Data.Attributes, actorUserID(c))
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusInvalidPayload, "", nil)
|
|
}
|
|
if err := h.svc.Update(c.UserContext(), row); err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.Update", err)
|
|
}
|
|
if req.Data.Attributes.Summary != nil {
|
|
if err := h.svc.ReviseSummary(c.UserContext(), row.HelicopterID, fleetSummaryManualInput(req.Data.Attributes.Summary), actorUserID(c)); err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.Update", err)
|
|
}
|
|
}
|
|
updated, err := h.svc.GetByID(c.UserContext(), id)
|
|
if err != nil {
|
|
slog.WarnContext(c.UserContext(), "fleet status re-fetch after update failed", slog.Any("error", err), slog.String("id", idString(id)))
|
|
}
|
|
if updated == nil {
|
|
updated = row
|
|
}
|
|
updatedStatus := h.deriveHelicopterStatus(c.UserContext(), updated.Helicopter, updated.HelicopterID)
|
|
return response.Write(c, fiber.StatusOK, fleetStatusDetailResource(c.UserContext(), h, updated, updatedStatus))
|
|
}
|
|
|
|
// DeleteFleetStatus godoc
|
|
// @Summary Delete fleet status
|
|
// @Description Soft delete fleet status by ID.
|
|
// @Tags Fleet Status
|
|
// @Produce json
|
|
// @Param id path string true "Fleet status ID (UUIDv7)"
|
|
// @Success 200 {object} responsedto.FleetStatusDeleteResponse
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Failure 422 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/fleet-status/delete/{id} [delete]
|
|
func (h *FleetStatusHandler) Delete(c *fiber.Ctx) error {
|
|
id, err := uuidv7.ParseString(c.Params("id"))
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusInvalidID, "", &jsonapi.ErrorSource{Pointer: "/path/id"})
|
|
}
|
|
if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.Delete", err)
|
|
}
|
|
return response.Write(c, fiber.StatusOK, map[string]any{"data": map[string]any{"type": "fleet_status_delete", "attributes": map[string]bool{"deleted": true}}})
|
|
}
|
|
|
|
// GetFleetStatusByID godoc
|
|
// @Summary Get fleet status by ID
|
|
// @Tags Fleet Status
|
|
// @Produce json
|
|
// @Param id path string true "Fleet status ID (UUIDv7)"
|
|
// @Success 200 {object} responsedto.FleetStatusResponse
|
|
// @Failure 404 {object} jsonapi.ErrorDocument
|
|
// @Failure 422 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/fleet-status/get/{id} [get]
|
|
func (h *FleetStatusHandler) GetByID(c *fiber.Ctx) error {
|
|
id, err := uuidv7.ParseString(c.Params("id"))
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusInvalidID, "", &jsonapi.ErrorSource{Pointer: "/path/id"})
|
|
}
|
|
row, err := h.svc.GetByID(c.UserContext(), id)
|
|
if err != nil || row == nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusNotFound, "", nil)
|
|
}
|
|
rowStatus := h.deriveHelicopterStatus(c.UserContext(), row.Helicopter, row.HelicopterID)
|
|
return response.Write(c, fiber.StatusOK, fleetStatusDetailResource(c.UserContext(), h, row, rowStatus))
|
|
}
|
|
|
|
// GetFleetStatusByHelicopterID godoc
|
|
// @Summary Get latest fleet status by helicopter ID
|
|
// @Tags Fleet Status
|
|
// @Produce json
|
|
// @Param helicopter_id path string true "Helicopter ID (UUIDv7)"
|
|
// @Success 200 {object} responsedto.FleetStatusResponse
|
|
// @Failure 404 {object} jsonapi.ErrorDocument
|
|
// @Failure 422 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/fleet-status/get-by-helicopter/{helicopter_id} [get]
|
|
func (h *FleetStatusHandler) GetByHelicopterID(c *fiber.Ctx) error {
|
|
helicopterID, err := uuidv7.ParseString(c.Params("helicopter_id"))
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusInvalidID, "helicopter_id is invalid UUID", &jsonapi.ErrorSource{Pointer: "/path/helicopter_id"})
|
|
}
|
|
latest, err := h.svc.LatestByHelicopterIDs(c.UserContext(), [][]byte{helicopterID})
|
|
if err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.GetByHelicopterID", err)
|
|
}
|
|
row := latest[string(helicopterID)]
|
|
if row == nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusNotFound, "fleet status not found for helicopter", nil)
|
|
}
|
|
rowStatus := h.deriveHelicopterStatus(c.UserContext(), row.Helicopter, row.HelicopterID)
|
|
return response.Write(c, fiber.StatusOK, fleetStatusDetailResource(c.UserContext(), h, row, rowStatus))
|
|
}
|
|
|
|
// ListFleetStatus godoc
|
|
// @Summary List fleet status
|
|
// @Description Lists every helicopter from the master, each with its latest fleet status (or empty data when it has none). Paginated by helicopter.
|
|
// @Tags Fleet Status
|
|
// @Produce json
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param page_size query int false "Page size" default(20)
|
|
// @Param filter query string false "Search helicopters by designation/identifier/type"
|
|
// @Param sort query string false "Sort helicopters, example: designation"
|
|
// @Param status query string false "Filter by operational status; comma-separated for multiple (e.g. available,booked). Values: available, booked, aog, mcf"
|
|
// @Param helicopter_id query string false "Filter by helicopter ID (UUIDv7); combined with status uses AND logic"
|
|
// @Success 200 {object} responsedto.FleetStatusListResponse
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/fleet-status/get-all [get]
|
|
func (h *FleetStatusHandler) List(c *fiber.Ctx) error {
|
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
size, _ := strconv.Atoi(c.Query("page_size", "20"))
|
|
if size < 1 {
|
|
size = 20
|
|
}
|
|
offset := (page - 1) * size
|
|
|
|
statuses, err := parseHelicopterStatusFilter(c.Query("status"))
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrInvalidRequest, "invalid status filter", nil)
|
|
}
|
|
|
|
// If helicopter_id is provided, fetch that single helicopter and apply status
|
|
// filter in Go (AND logic). Pagination is irrelevant for a single result.
|
|
if raw := strings.TrimSpace(c.Query("helicopter_id")); raw != "" {
|
|
heliID, err := uuidv7.ParseString(raw)
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrInvalidRequest, "invalid helicopter_id", &jsonapi.ErrorSource{Pointer: "/query/helicopter_id"})
|
|
}
|
|
resources, err := h.singleHelicopterResources(c.UserContext(), heliID, statuses)
|
|
if err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.List", err)
|
|
}
|
|
return response.WriteWithMeta(c, fiber.StatusOK, resources, map[string]any{
|
|
"page_number": page,
|
|
"page_size": size,
|
|
"total": int64(len(resources)),
|
|
})
|
|
}
|
|
|
|
var groundedIDs [][]byte
|
|
if len(statuses) > 0 {
|
|
groundedIDs = groundedHelicopterIDs(c.UserContext(), h.complaintSvc, time.Now().UTC())
|
|
}
|
|
|
|
// Drive the listing from the helicopter master so each helicopter carries its
|
|
// latest fleet status (or empty data when it has none).
|
|
helis, total, err := h.helicopterSvc.List(c.UserContext(), c.Query("filter"), statuses, c.Query("sort"), size, offset, groundedIDs)
|
|
if err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.List", err)
|
|
}
|
|
resources, err := h.fleetStatusResourcesForHelicopters(c.UserContext(), helis)
|
|
if err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.List", err)
|
|
}
|
|
sortFleetStatusResourcesByStatus(resources)
|
|
meta := map[string]any{
|
|
"page_number": page,
|
|
"page_size": size,
|
|
"total": total,
|
|
}
|
|
return response.WriteWithMeta(c, fiber.StatusOK, resources, meta)
|
|
}
|
|
|
|
// ListFleetStatusDatatable godoc
|
|
// @Summary List fleet status (datatable)
|
|
// @Description Lists every helicopter from the master, each with its latest fleet status (or empty data when it has none). Paginated by helicopter.
|
|
// @Tags Fleet Status
|
|
// @Produce json
|
|
// @Param draw query int false "Datatable draw counter" default(1)
|
|
// @Param start query int false "Datatable start offset" default(0)
|
|
// @Param length query int false "Datatable page length" default(10)
|
|
// @Param search query string false "Search helicopters by designation/identifier/type"
|
|
// @Param sort query string false "Sort helicopters, example: designation"
|
|
// @Param status query string false "Filter by operational status; comma-separated for multiple (e.g. available,booked). Values: available, booked, aog, mcf"
|
|
// @Param helicopter_id query string false "Filter by helicopter ID (UUIDv7); combined with status uses AND logic"
|
|
// @Success 200 {object} responsedto.FleetStatusDataTableResponse
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/fleet-status/get-all/dt [get]
|
|
func (h *FleetStatusHandler) ListDatatable(c *fiber.Ctx) error {
|
|
start, _ := strconv.Atoi(c.Query("start", "0"))
|
|
length, _ := strconv.Atoi(c.Query("length", "10"))
|
|
draw, _ := strconv.Atoi(c.Query("draw", "1"))
|
|
if length < 1 {
|
|
length = 10
|
|
}
|
|
|
|
statuses, err := parseHelicopterStatusFilter(c.Query("status"))
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrInvalidRequest, "invalid status filter", nil)
|
|
}
|
|
|
|
// If helicopter_id is provided, fetch that single helicopter and apply status
|
|
// filter in Go (AND logic).
|
|
if raw := strings.TrimSpace(c.Query("helicopter_id")); raw != "" {
|
|
heliID, err := uuidv7.ParseString(raw)
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrInvalidRequest, "invalid helicopter_id", &jsonapi.ErrorSource{Pointer: "/query/helicopter_id"})
|
|
}
|
|
resources, err := h.singleHelicopterResources(c.UserContext(), heliID, statuses)
|
|
if err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.ListDatatable", err)
|
|
}
|
|
total := int64(len(resources))
|
|
return response.WriteWithMeta(c, fiber.StatusOK, resources, map[string]any{
|
|
"draw": draw,
|
|
"records_total": total,
|
|
"records_filtered": total,
|
|
})
|
|
}
|
|
|
|
var groundedIDs [][]byte
|
|
if len(statuses) > 0 {
|
|
groundedIDs = groundedHelicopterIDs(c.UserContext(), h.complaintSvc, time.Now().UTC())
|
|
}
|
|
|
|
helis, total, err := h.helicopterSvc.List(c.UserContext(), c.Query("search"), statuses, c.Query("sort"), length, start, groundedIDs)
|
|
if err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.ListDatatable", err)
|
|
}
|
|
resources, err := h.fleetStatusResourcesForHelicopters(c.UserContext(), helis)
|
|
if err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.ListDatatable", err)
|
|
}
|
|
sortFleetStatusResourcesByStatus(resources)
|
|
meta := map[string]any{
|
|
"draw": draw,
|
|
"records_total": total,
|
|
"records_filtered": total,
|
|
}
|
|
return response.WriteWithMeta(c, fiber.StatusOK, resources, meta)
|
|
}
|
|
|
|
func fleetStatusResourceStatus(r responsedto.FleetStatusResource) string {
|
|
if r.Attributes.Helicopter != nil {
|
|
return r.Attributes.Helicopter.Status
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// sortFleetStatusResourcesByStatus stably orders the resources by status rank, keeping
|
|
// the incoming (designation/sort) order within the same status.
|
|
func sortFleetStatusResourcesByStatus(resources []responsedto.FleetStatusResource) {
|
|
sort.SliceStable(resources, func(i, j int) bool {
|
|
return helicopter.StatusRank(fleetStatusResourceStatus(resources[i])) < helicopter.StatusRank(fleetStatusResourceStatus(resources[j]))
|
|
})
|
|
}
|
|
|
|
// MarkFleetStatusServiced godoc
|
|
// @Summary Mark fleet status serviced
|
|
// @Description Move current maintenance schedules into service logs, then clear active schedules.
|
|
// @Tags Fleet Status
|
|
// @Produce json
|
|
// @Param id path string true "Fleet status ID (UUIDv7)"
|
|
// @Success 200 {object} responsedto.FleetStatusMarkServicedResponse
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Failure 404 {object} jsonapi.ErrorDocument
|
|
// @Failure 422 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/fleet-status/mark-serviced/{id} [post]
|
|
func (h *FleetStatusHandler) MarkServiced(c *fiber.Ctx) error {
|
|
id, err := uuidv7.ParseString(c.Params("id"))
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusInvalidID, "", &jsonapi.ErrorSource{Pointer: "/path/id"})
|
|
}
|
|
|
|
if err := h.svc.MarkServiced(c.UserContext(), id, time.Now().UTC(), actorUserID(c)); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusNotFound, "", nil)
|
|
}
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.MarkServiced", err)
|
|
}
|
|
|
|
return response.Write(c, fiber.StatusOK, map[string]any{
|
|
"data": map[string]any{
|
|
"type": "fleet_status_mark_serviced",
|
|
"attributes": map[string]any{
|
|
"serviced": true,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// ListFleetStatusServiceHistory godoc
|
|
// @Summary List fleet history (timeline) by helicopter
|
|
// @Description Unified timeline: maintenance servicing events merged with complaint/MEL/NSR/action/MCF/EASA/AOG action events, newest first.
|
|
// @Tags Fleet Status
|
|
// @Produce json
|
|
// @Param helicopter_id path string true "Helicopter ID (UUIDv7)"
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Param page_size query int false "Page size" default(20)
|
|
// @Success 200 {object} responsedto.FleetHistoryListResponse
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Failure 422 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/fleet-status/history/{helicopter_id} [get]
|
|
func (h *FleetStatusHandler) ListHistoryByHelicopter(c *fiber.Ctx) error {
|
|
helicopterID, err := uuidv7.ParseString(c.Params("helicopter_id"))
|
|
if err != nil {
|
|
return writeFleetStatusAppError(c, apperrorsx.ErrFleetStatusInvalidID, "helicopter_id is invalid UUID", &jsonapi.ErrorSource{Pointer: "/path/helicopter_id"})
|
|
}
|
|
|
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
size, _ := strconv.Atoi(c.Query("page_size", "20"))
|
|
if size < 1 {
|
|
size = 20
|
|
}
|
|
offset := (page - 1) * size
|
|
|
|
type timedEntry struct {
|
|
at time.Time
|
|
entry responsedto.FleetHistoryEntry
|
|
}
|
|
items := make([]timedEntry, 0)
|
|
|
|
logs, _, err := h.svc.ListServiceHistoryByHelicopterID(c.UserContext(), helicopterID, 1000, 0)
|
|
if err != nil {
|
|
return writeFleetStatusServiceError(c, "FleetStatusHandler.ListHistoryByHelicopter", err)
|
|
}
|
|
for i := range logs {
|
|
l := &logs[i]
|
|
items = append(items, timedEntry{at: l.ServicedAt.UTC(), entry: responsedto.FleetHistoryEntry{
|
|
At: l.ServicedAt.UTC().Format(time.RFC3339),
|
|
EntityType: "fleet_status",
|
|
EntityID: idString(l.FleetStatusID),
|
|
Action: "serviced",
|
|
Message: "Serviced " + strings.TrimSpace(l.InspectionType+" "+l.Type),
|
|
Actor: idString(l.CreatedBy),
|
|
ActorName: userctx.GetDisplayName(c.UserContext(), l.CreatedBy),
|
|
}})
|
|
}
|
|
|
|
events, _, herr := h.historySvc.ListByHelicopter(c.UserContext(), helicopterID, 0, 0)
|
|
if herr == nil {
|
|
for i := range events {
|
|
e := &events[i]
|
|
items = append(items, timedEntry{at: e.CreatedAt.UTC(), entry: responsedto.FleetHistoryEntry{
|
|
At: e.CreatedAt.UTC().Format(time.RFC3339),
|
|
EntityType: e.EntityType,
|
|
EntityID: idString(e.EntityID),
|
|
Action: e.Action,
|
|
Message: e.Message,
|
|
Actor: idString(e.Actor),
|
|
ActorName: userctx.GetDisplayName(c.UserContext(), e.Actor),
|
|
}})
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(items, func(i, j int) bool { return items[i].at.After(items[j].at) })
|
|
|
|
total := int64(len(items))
|
|
start := offset
|
|
if start > len(items) {
|
|
start = len(items)
|
|
}
|
|
end := start + size
|
|
if end > len(items) {
|
|
end = len(items)
|
|
}
|
|
data := make([]responsedto.FleetHistoryEntry, 0, end-start)
|
|
for _, it := range items[start:end] {
|
|
data = append(data, it.entry)
|
|
}
|
|
res := responsedto.FleetHistoryListResponse{Data: data}
|
|
res.Meta.PageNumber = page
|
|
res.Meta.PageSize = size
|
|
res.Meta.Total = total
|
|
return response.Write(c, fiber.StatusOK, res)
|
|
}
|
|
|
|
// singleHelicopterResources fetches one helicopter by ID, builds its fleet
|
|
// status resource, and applies an optional status filter (AND logic). Returns
|
|
// an empty slice when the helicopter is not found or the status does not match.
|
|
func (h *FleetStatusHandler) singleHelicopterResources(ctx context.Context, helicopterID []byte, statuses []string) ([]responsedto.FleetStatusResource, error) {
|
|
heli, err := h.helicopterSvc.GetByID(ctx, helicopterID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if heli == nil {
|
|
return []responsedto.FleetStatusResource{}, nil
|
|
}
|
|
resources, err := h.fleetStatusResourcesForHelicopters(ctx, []helicopter.Helicopter{*heli})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(statuses) > 0 && len(resources) > 0 {
|
|
heliStatus := ""
|
|
if resources[0].Attributes.Helicopter != nil {
|
|
heliStatus = resources[0].Attributes.Helicopter.Status
|
|
}
|
|
for _, s := range statuses {
|
|
if s == heliStatus {
|
|
return resources, nil
|
|
}
|
|
}
|
|
return []responsedto.FleetStatusResource{}, nil
|
|
}
|
|
return resources, nil
|
|
}
|
|
|
|
func writeFleetStatusAppError(c *fiber.Ctx, def apperrorsx.Def, detail string, source *jsonapi.ErrorSource) error {
|
|
appErr := apperrorsx.New(def)
|
|
if strings.TrimSpace(detail) != "" {
|
|
appErr.Message = detail
|
|
}
|
|
if source != nil {
|
|
appErr.Source = source
|
|
}
|
|
return apperrorsx.Write(c, appErr)
|
|
}
|
|
|
|
func writeFleetStatusValidationErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error {
|
|
statusStr := strconv.Itoa(status)
|
|
enriched := make([]jsonapi.ErrorObject, 0, len(errs))
|
|
for i := range errs {
|
|
e := errs[i]
|
|
e.Status = statusStr
|
|
if strings.TrimSpace(e.Code) == "" || strings.TrimSpace(e.ErrorCode) == "" {
|
|
code, errorCode := inferFleetStatusErrorCode(status, e.Title, e.Detail)
|
|
if strings.TrimSpace(e.Code) == "" {
|
|
e.Code = code
|
|
}
|
|
if strings.TrimSpace(e.ErrorCode) == "" {
|
|
e.ErrorCode = errorCode
|
|
}
|
|
}
|
|
enriched = append(enriched, e)
|
|
}
|
|
return jsonapi.WriteErrors(c, status, enriched)
|
|
}
|
|
|
|
func writeFleetStatusServiceError(c *fiber.Ctx, operation string, err error) error {
|
|
mapped := apperrorsx.Translate(apperrorsx.ModuleFleetStatus, err)
|
|
if mapped != nil && mapped.Code != apperrorsx.CodeInternalServerError {
|
|
logFleetStatusServiceError(c, operation, mapped.Status, mapped.Code, mapped.ErrorCode, err)
|
|
return apperrorsx.Write(c, mapped)
|
|
}
|
|
|
|
logFleetStatusServiceError(c, operation, fiber.StatusInternalServerError, apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode, err)
|
|
appErr := apperrorsx.New(apperrorsx.ErrInternal)
|
|
appErr.Message = "an internal error occurred"
|
|
return apperrorsx.Write(c, appErr)
|
|
}
|
|
|
|
func logFleetStatusServiceError(c *fiber.Ctx, operation string, status int, code, errorCode string, err error) {
|
|
requestID := requestIDFromCtx(c)
|
|
args := []any{
|
|
slog.String("request_id", requestID),
|
|
slog.String("operation", strings.TrimSpace(operation)),
|
|
slog.Int("status", status),
|
|
slog.String("code", strings.TrimSpace(code)),
|
|
slog.String("error_code", strings.TrimSpace(errorCode)),
|
|
slog.String("path", c.Path()),
|
|
slog.Any("cause", err),
|
|
}
|
|
if status >= fiber.StatusInternalServerError {
|
|
slog.ErrorContext(c.UserContext(), "fleet status service error", args...)
|
|
return
|
|
}
|
|
slog.WarnContext(c.UserContext(), "fleet status service error", args...)
|
|
}
|
|
|
|
func inferFleetStatusErrorCode(status int, _ string, _ string) (string, string) {
|
|
switch status {
|
|
case fiber.StatusNotFound:
|
|
return apperrorsx.CodeFleetStatusNotFound, apperrorsx.ErrFleetStatusNotFound.ErrorCode
|
|
case fiber.StatusUnprocessableEntity:
|
|
return apperrorsx.CodeFleetStatusInvalidPayload, apperrorsx.ErrFleetStatusInvalidPayload.ErrorCode
|
|
case fiber.StatusBadRequest:
|
|
return apperrorsx.CodeFleetStatusInvalidJSON, apperrorsx.ErrFleetStatusInvalidJSON.ErrorCode
|
|
default:
|
|
return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode
|
|
}
|
|
}
|