init push
This commit is contained in:
634
internal/transport/http/handlers/fleet_status_handler.go
Normal file
634
internal/transport/http/handlers/fleet_status_handler.go
Normal file
@@ -0,0 +1,634 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user