Files
fm_be/internal/transport/http/handlers/complaint_handler.go
2026-07-16 22:16:45 +07:00

1342 lines
55 KiB
Go

package handlers
import (
"context"
"log/slog"
"runtime/debug"
"sort"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
actionsignoff "wucher/internal/domain/action_signoff"
complaint "wucher/internal/domain/complaint"
fleethistory "wucher/internal/domain/fleet_history"
"wucher/internal/domain/helicopter"
"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"
shareddto "wucher/internal/transport/http/dto/shared"
"wucher/internal/transport/http/jsonapi"
"wucher/internal/transport/http/response"
"wucher/internal/transport/http/validators"
)
type ComplaintHandler struct {
svc complaint.Service
helicopterSvc helicopter.Service
mcfSvc mcfPendingChecker
historySvc fleetHistoryRecorder
aogRecalc aogRecalculator
signoffSvc complaintSignoffManager
validate *validators.Validator
}
// complaintSignoffLookup batch-loads the corrective-action sign-offs for complaints,
// keyed by complaint id — the sign state now lives in action_signoff, not on the complaint.
type complaintSignoffLookup interface {
GetByComplaintIDs(ctx context.Context, complaintIDs [][]byte) (map[string]*actionsignoff.ActionSignoff, error)
}
// complaintSignoffManager adds the write used when a complaint is updated: reopening a
// (previously signed) complaint invalidates its sign-off, so it is unsigned.
type complaintSignoffManager interface {
complaintSignoffLookup
UnsignByComplaint(ctx context.Context, complaintID []byte) (*actionsignoff.ActionSignoff, error)
}
type mcfPendingChecker interface {
PendingHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]bool, error)
}
type aogRecalculator interface {
RecalculateAOGByHelicopter(ctx context.Context, helicopterID []byte) error
}
func mcfPendingHelicopterSet(ctx context.Context, svc mcfPendingChecker, ids [][]byte) map[string]bool {
if svc == nil || len(ids) == 0 {
return map[string]bool{}
}
if set, err := svc.PendingHelicopterIDs(ctx, ids); err == nil && set != nil {
return set
}
return map[string]bool{}
}
func NewComplaintHandler(svc complaint.Service) *ComplaintHandler {
return &ComplaintHandler{svc: svc, validate: validators.New()}
}
func (h *ComplaintHandler) WithHelicopterService(svc helicopter.Service) *ComplaintHandler {
h.helicopterSvc = svc
return h
}
func (h *ComplaintHandler) WithMCFService(svc mcfPendingChecker) *ComplaintHandler {
h.mcfSvc = svc
return h
}
func (h *ComplaintHandler) WithAOGRecalculator(svc aogRecalculator) *ComplaintHandler {
h.aogRecalc = svc
return h
}
func (h *ComplaintHandler) WithHistoryService(svc fleetHistoryRecorder) *ComplaintHandler {
h.historySvc = svc
return h
}
func (h *ComplaintHandler) WithActionSignoffService(svc complaintSignoffManager) *ComplaintHandler {
h.signoffSvc = svc
return h
}
func (h *ComplaintHandler) complaintResource(ctx context.Context, row *complaint.Complaint, names map[string]string, signoffs map[string]*actionsignoff.ActionSignoff) responsedto.ComplaintResource {
if row == nil {
return responsedto.ComplaintResource{}
}
signoff := signoffs[string(row.ID)]
id, _ := uuidv7.BytesToString(row.ID)
reportedAt := ""
if !row.ReportedAt.IsZero() {
reportedAt = row.ReportedAt.UTC().Format(time.RFC3339)
}
createdAt := ""
if !row.CreatedAt.IsZero() {
createdAt = row.CreatedAt.UTC().Format(time.RFC3339)
}
updatedAt := ""
if !row.UpdatedAt.IsZero() {
updatedAt = row.UpdatedAt.UTC().Format(time.RFC3339)
}
out := responsedto.ComplaintResource{
Type: "complaint",
ID: id,
Attributes: responsedto.ComplaintAttributes{
HelicopterID: idString(row.HelicopterID),
FlightID: idString(row.FlightID),
Description: row.Description,
ReportedBy: names[string(row.ReportedBy)],
ReportedByShort: userctx.GetShortName(ctx, row.ReportedBy),
ReportedAt: reportedAt,
AircraftHoursAtReport: row.AircraftHoursAtReport,
MEL: responsedto.ComplaintMEL{
Severity: row.MELSeverity,
},
NSR: responsedto.ComplaintNSR{
IsNSR: row.IsNSR,
Reason: row.NSRReason,
},
ActionSigned: signoff.IsSigned(),
ActionTaken: row.ActionTaken,
AircraftHoursAtFix: row.AircraftHoursAtFix,
Status: row.Status(),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
}
if n := names[string(row.MELClassifiedBy)]; n != "" {
out.Attributes.MEL.ClassifiedByName = &n
}
if s := userctx.GetShortName(ctx, row.MELClassifiedBy); s != "" {
out.Attributes.MEL.ClassifiedByShort = &s
}
if signoff != nil {
if n := names[string(signoff.SignedBy)]; n != "" {
out.Attributes.ActionSignedByName = &n
}
if s := userctx.GetShortName(ctx, signoff.SignedBy); s != "" {
out.Attributes.ActionSignedByShort = &s
}
}
// Only show a severity label once classified — a pending complaint's severity 0 is a
// default, not a "Non-MEL" decision, so it must not render as such.
if row.MELClassifiedAt != nil {
v := row.MELClassifiedAt.UTC().Format(time.RFC3339)
out.Attributes.MEL.ClassifiedAt = &v
if label := complaintSeverityLabel(row.MELSeverity); label != "" {
out.Attributes.MEL.SeverityLabel = &label
}
}
if dl := row.MELDeadline(); dl != nil {
v := dl.UTC().Format(time.RFC3339)
out.Attributes.MEL.Deadline = &v
}
if row.NSRDecidedAt != nil {
v := row.NSRDecidedAt.UTC().Format(time.RFC3339)
out.Attributes.NSR.DecidedAt = &v
}
if n := names[string(row.NSRDecidedBy)]; n != "" {
out.Attributes.NSR.DecidedByName = &n
}
if s := userctx.GetShortName(ctx, row.NSRDecidedBy); s != "" {
out.Attributes.NSR.DecidedByShort = &s
}
if signoff != nil && signoff.SignedAt != nil {
v := signoff.SignedAt.UTC().Format(time.RFC3339)
out.Attributes.ActionSignedAt = &v
}
return out
}
func complaintHelicopterKey(row *complaint.Complaint) string {
if row == nil || len(row.HelicopterID) == 0 {
return "unknown"
}
return string(row.HelicopterID)
}
// CreateComplaint godoc
// @Summary Create complaint
// @Description Create a complaint (defect) for a flight inspection.
// @Description MEL severity (mel_severity, OPTIONAL): omit it to file a "pending_mel" complaint and classify later (via PATCH /complaints/update/{id}); it MUST be classified before recording action taken. A pending complaint does NOT ground the aircraft — grounding starts at classification. Values: 0 = NON-MEL (grounds immediately); 1 = A (1 day grace); 2 = B (3 days); 3 = C (10 days); 4 = D (120 days). After the grace period passes without action the aircraft becomes AOG.
// @Description NSR (Non-Safety Release) is its own flow — sign it at POST /api/v1/complaints/nsr/sign/{id} (cancel at /nsr/unsign/{id}); it is no longer set via create/update.
// @Description Optional fields: aircraft_hours_at_report.
// @Description To record the corrective action, use POST /api/v1/complaints/action-taken/create/{id} (a separate, explicitly-signed step) — it is no longer part of create/update.
// @Tags Complaints
// @Accept json
// @Produce json
// @Param request body requestdto.ComplaintCreateRequest true "JSON:API complaint create request"
// @Success 201 {object} responsedto.ComplaintResponse
// @Failure 400 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/create [post]
func (h *ComplaintHandler) Create(c *fiber.Ctx) error {
var req requestdto.ComplaintCreateRequest
if err := c.BodyParser(&req); err != nil {
return writeBodyParseError(c, err, apperrorsx.ErrComplaintInvalidJSON)
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, errs)
}
helicopterID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.HelicopterID))
if err != nil {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/helicopter_id"}
return apperrorsx.Write(c, appErr)
}
var flightID []byte
if raw := strings.TrimSpace(req.Data.Attributes.FlightID); raw != "" {
flightID, err = uuidv7.ParseString(raw)
if err != nil {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/flight_id"}
return apperrorsx.Write(c, appErr)
}
}
// An aircraft may carry several open defects at once (each its own hold item with
// its own MEL grace), so a new complaint is NOT blocked by existing open ones —
// consistent with the AOG/gate logic which already handles a list of open complaints.
// NSR is not decided here — it is its own flow (POST /complaints/nsr/sign/{id}).
row := &complaint.Complaint{
HelicopterID: helicopterID,
FlightID: flightID,
Description: strings.TrimSpace(req.Data.Attributes.Description),
ReportedBy: actorUserID(c),
ReportedAt: time.Now().UTC(),
AircraftHoursAtReport: req.Data.Attributes.AircraftHoursAtReport,
CreatedBy: actorUserID(c),
UpdatedBy: actorUserID(c),
}
if row.Description == "" {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidPayload)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/description"}
return apperrorsx.Write(c, appErr)
}
now := time.Now().UTC()
actor := actorUserID(c)
// mel_severity is optional at creation: when provided the complaint is classified
// immediately; when omitted it stays "pending_mel" (mel_classified_at NULL) — it does
// NOT ground the aircraft yet (an unassessed report isn't a grounding defect), and it
// must be classified before action taken can be recorded.
if req.Data.Attributes.MELSeverity != nil {
row.MELSeverity = *req.Data.Attributes.MELSeverity
row.MELClassifiedAt = &now
row.MELClassifiedBy = append([]byte(nil), actor...)
}
if err := h.svc.Create(c.UserContext(), row); err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintCreateFailed))
}
created, _ := h.svc.GetByID(c.UserContext(), row.ID)
if created == nil {
created = row
}
h.recordComplaintFiled(c, created)
if h.aogRecalc != nil {
_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), created.HelicopterID)
}
names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*created})
signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*created}, names)
res := h.complaintResource(c.UserContext(), created, names, signoffs)
res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), created.HelicopterID)
return response.Write(c, fiber.StatusCreated, responsedto.ComplaintResponse{Data: res})
}
// GetComplaint godoc
// @Summary Get complaint by ID
// @Tags Complaints
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/get/{id} [get]
func (h *ComplaintHandler) GetByID(c *fiber.Ctx) error {
id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
if err != nil {
return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}}})
}
row, err := h.svc.GetByID(c.UserContext(), id)
if err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintNotFound))
}
if row == nil {
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintNotFound))
}
names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*row})
signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*row}, names)
res := h.complaintResource(c.UserContext(), row, names, signoffs)
res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), row.HelicopterID)
return response.Write(c, fiber.StatusOK, responsedto.ComplaintResponse{Data: res})
}
// UpdateComplaint godoc
// @Summary Update complaint
// @Description Update a complaint. All attributes optional; only provided fields change.
// @Description mel_severity: 0 = NON-MEL (immediate AOG); 1 = A (1 day); 2 = B (3 days); 3 = C (10 days); 4 = D (120 days) — AOG after grace passes.
// @Description NSR is not set here — it is its own flow: POST /api/v1/complaints/nsr/sign/{id} (and /nsr/unsign/{id}).
// @Description Note: the corrective action is recorded via POST /api/v1/complaints/action-taken/create/{id} (a separate, signed step), not here.
// @Tags Complaints
// @Accept json
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Param request body requestdto.ComplaintUpdateRequest true "JSON:API complaint update request"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/update/{id} [patch]
func (h *ComplaintHandler) Update(c *fiber.Ctx) error {
id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
if err != nil {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
return apperrorsx.Write(c, appErr)
}
var req requestdto.ComplaintUpdateRequest
if err := c.BodyParser(&req); err != nil {
if appErr := bodyParseError(err); appErr != nil {
return apperrorsx.Write(c, appErr)
}
return writeComplaintErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, errs)
}
if strings.TrimSpace(req.Data.ID) == "" {
appErr := apperrorsx.New(apperrorsx.ErrComplaintIDRequired)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/id"}
return apperrorsx.Write(c, appErr)
}
if strings.TrimSpace(req.Data.ID) != strings.TrimSpace(c.Params("id")) {
appErr := apperrorsx.New(apperrorsx.ErrComplaintIDMismatch)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/id"}
return apperrorsx.Write(c, appErr)
}
row, err := h.svc.GetByID(c.UserContext(), id)
if err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintNotFound))
}
if row == nil {
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintNotFound))
}
// NSR is decided via its own endpoint (POST /complaints/nsr/sign|unsign/{id}); the
// shared update no longer touches it.
if req.Data.Attributes.Description != nil {
v := strings.TrimSpace(*req.Data.Attributes.Description)
if v == "" {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidPayload)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/description"}
return apperrorsx.Write(c, appErr)
}
row.Description = v
}
if req.Data.Attributes.MELSeverity != nil {
row.MELSeverity = *req.Data.Attributes.MELSeverity
now := time.Now().UTC()
row.MELClassifiedAt = &now
row.MELClassifiedBy = append([]byte(nil), actorUserID(c)...)
}
if req.Data.Attributes.AircraftHoursAtReport != nil {
row.AircraftHoursAtReport = req.Data.Attributes.AircraftHoursAtReport
}
row.UpdatedBy = actorUserID(c)
// Updating a complaint reopens its lifecycle: a previously closed complaint is
// unfixed (AOG is recomputed below, re-grounding the aircraft) so a new EASA
// release is required; the old release stays as history.
if row.FixedAt != nil {
row.FixedAt = nil
row.FixedByEASAID = nil
}
if err := h.svc.Update(c.UserContext(), row); err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintUpdateFailed))
}
// The change invalidates any corrective-action sign-off: it must be re-recorded and
// re-signed. No-op when the complaint was never signed off.
if h.signoffSvc != nil {
_, _ = h.signoffSvc.UnsignByComplaint(c.UserContext(), row.ID)
}
if h.aogRecalc != nil {
_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), row.HelicopterID)
}
updated, _ := h.svc.GetByID(c.UserContext(), id)
if updated == nil {
updated = row
}
h.recordComplaintUpdate(c, req.Data.Attributes, updated)
names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*updated})
signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*updated}, names)
res := h.complaintResource(c.UserContext(), updated, names, signoffs)
res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), updated.HelicopterID)
return response.Write(c, fiber.StatusOK, responsedto.ComplaintResponse{Data: res})
}
// ComplaintActionTaken godoc
// @Summary Record the corrective action for a complaint
// @Description Records the corrective action text taken for a complaint. Recording the action does NOT sign it and does NOT lift the AOG grounding: signing the action is a separate step at POST /api/v1/action-signoffs/sign/{helicopter_id} (send complaint_id), and returning the aircraft to service requires a formal release (a signed EASA release / CRS, or an NSR). The complaint stays open until an EASA release fixes it.
// @Tags Complaints
// @Accept json
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Param request body requestdto.ComplaintActionTakenRequest true "Action taken payload"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 400 {object} jsonapi.ErrorDocument
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/action-taken/create/{id} [post]
func (h *ComplaintHandler) ActionTaken(c *fiber.Ctx) error {
return h.applyActionTaken(c, false)
}
// UpdateComplaintActionTaken godoc
// @Summary Update the recorded corrective action for a complaint
// @Description Updates the previously-recorded corrective action text of a complaint. Recording/updating the action does NOT sign it — signing is a separate step at POST /api/v1/action-signoffs/sign/{helicopter_id} (send complaint_id).
// @Tags Complaints
// @Accept json
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Param request body requestdto.ComplaintActionTakenRequest true "Action taken payload"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 400 {object} jsonapi.ErrorDocument
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/action-taken/update/{id} [patch]
func (h *ComplaintHandler) UpdateActionTaken(c *fiber.Ctx) error {
return h.applyActionTaken(c, true)
}
func (h *ComplaintHandler) applyActionTaken(c *fiber.Ctx, allowClear bool) error {
id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
if err != nil {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
return apperrorsx.Write(c, appErr)
}
var req requestdto.ComplaintActionTakenRequest
if err := c.BodyParser(&req); err != nil {
if appErr := bodyParseError(err); appErr != nil {
return apperrorsx.Write(c, appErr)
}
return writeComplaintErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}})
}
if errs := h.validate.ValidateStruct(req); errs != nil {
return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, errs)
}
action := strings.TrimSpace(req.Data.Attributes.ActionTaken)
// Empty action_taken clears it. Clearing is allowed only on update and only while the
// action has not been signed off yet; recording (create) still requires a value.
if action == "" && !allowClear {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidPayload)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/action_taken"}
return apperrorsx.Write(c, appErr)
}
row, err := h.svc.GetByID(c.UserContext(), id)
if err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintNotFound))
}
if row == nil {
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintNotFound))
}
// MEL must be classified before a corrective action can be recorded: a "pending_mel"
// defect has no agreed severity/deferral, so acting on it is premature. Classify it
// first via PATCH /complaints/update/{id} (mel_severity).
if row.MELClassifiedAt == nil {
appErr := apperrorsx.New(apperrorsx.ErrComplaintMELRequired)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/mel_severity"}
return apperrorsx.Write(c, appErr)
}
actor := actorUserID(c)
// Record only the action text here; signing is done at POST /action-signoffs/sign
// (with complaint_id). Empty clears it — but a signed-off action cannot be cleared.
if action == "" {
if h.signoffSvc != nil {
if signoffs, serr := h.signoffSvc.GetByComplaintIDs(c.UserContext(), [][]byte{id}); serr == nil {
if so := signoffs[string(id)]; so != nil && so.IsSigned() {
appErr := apperrorsx.New(apperrorsx.ErrComplaintActionSignedLocked)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/action_taken"}
return apperrorsx.Write(c, appErr)
}
}
}
row.ActionTaken = nil
} else {
row.ActionTaken = &action
}
if req.Data.Attributes.AircraftHoursAtFix != nil {
row.AircraftHoursAtFix = emptyToNilString(req.Data.Attributes.AircraftHoursAtFix)
}
row.UpdatedBy = actor
if err := h.svc.Update(c.UserContext(), row); err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintUpdateFailed))
}
if h.aogRecalc != nil {
_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), row.HelicopterID)
}
updated, _ := h.svc.GetByID(c.UserContext(), id)
if updated == nil {
updated = row
}
if h.historySvc != nil {
msg := "Action taken cleared"
if action != "" {
msg = "Action taken — " + action
}
_ = h.historySvc.Record(c.UserContext(), updated.HelicopterID, fleethistory.EntityComplaint, updated.ID, fleethistory.ActionActionTaken, msg, actor)
}
names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*updated})
signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*updated}, names)
res := h.complaintResource(c.UserContext(), updated, names, signoffs)
res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), updated.HelicopterID)
return response.Write(c, fiber.StatusOK, responsedto.ComplaintResponse{Data: res})
}
// NSRSign godoc
// @Summary Sign a Non-Safety Release (NSR / MEL deferral) for a complaint
// @Description Records an NSR decision on its own flow (separate from complaint update): the aircraft is released to fly despite the open defect and is no longer grounded by it. MEL must be classified first — an unassessed (pending_mel) defect cannot be deferred, so a pending complaint returns 422. The complaint stays OPEN and is only closed by a signed EASA release. Requires the complaint.nsr permission.
// @Tags Complaints
// @Accept json
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Param request body requestdto.ComplaintNSRSignRequest false "Optional NSR reason"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 404 {object} jsonapi.ErrorDocument
// @Failure 422 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/nsr/sign/{id} [post]
func (h *ComplaintHandler) NSRSign(c *fiber.Ctx) error {
id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
if err != nil {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
return apperrorsx.Write(c, appErr)
}
var req requestdto.ComplaintNSRSignRequest
// Body is optional (reason only); tolerate an empty body.
_ = c.BodyParser(&req)
row, err := h.svc.GetByID(c.UserContext(), id)
if err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintNotFound))
}
if row == nil {
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintNotFound))
}
// NSR is a MEL deferral: the defect must be classified before it can be deferred.
if row.MELClassifiedAt == nil {
appErr := apperrorsx.New(apperrorsx.ErrComplaintMELRequired)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/mel_severity"}
return apperrorsx.Write(c, appErr)
}
now := time.Now().UTC()
actor := actorUserID(c)
row.IsNSR = true
row.NSRDecidedAt = &now
row.NSRDecidedBy = append([]byte(nil), actor...)
if req.Data.Attributes.NSRReason != nil {
row.NSRReason = emptyToNilString(req.Data.Attributes.NSRReason)
}
row.UpdatedBy = actor
if err := h.svc.Update(c.UserContext(), row); err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintUpdateFailed))
}
if h.aogRecalc != nil {
_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), row.HelicopterID)
}
updated, _ := h.svc.GetByID(c.UserContext(), id)
if updated == nil {
updated = row
}
if h.historySvc != nil {
_ = h.historySvc.Record(c.UserContext(), updated.HelicopterID, fleethistory.EntityComplaint, updated.ID, fleethistory.ActionNSRDone, nsrLogMessage(updated), actor)
}
names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*updated})
signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*updated}, names)
res := h.complaintResource(c.UserContext(), updated, names, signoffs)
res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), updated.HelicopterID)
return response.Write(c, fiber.StatusOK, responsedto.ComplaintResponse{Data: res})
}
// NSRUnsign godoc
// @Summary Cancel a Non-Safety Release for a complaint
// @Description Revokes the NSR decision: the deferral is lifted, so the still-open defect grounds the aircraft again (AOG is recomputed). Requires the complaint.nsr permission.
// @Tags Complaints
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Success 200 {object} responsedto.ComplaintResponse
// @Failure 404 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/nsr/unsign/{id} [post]
func (h *ComplaintHandler) NSRUnsign(c *fiber.Ctx) error {
id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
if err != nil {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
return apperrorsx.Write(c, appErr)
}
row, err := h.svc.GetByID(c.UserContext(), id)
if err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintNotFound))
}
if row == nil {
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintNotFound))
}
actor := actorUserID(c)
row.IsNSR = false
row.NSRDecidedAt = nil
row.NSRDecidedBy = nil
row.NSRReason = nil
row.UpdatedBy = actor
if err := h.svc.Update(c.UserContext(), row); err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintUpdateFailed))
}
if h.aogRecalc != nil {
_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), row.HelicopterID)
}
updated, _ := h.svc.GetByID(c.UserContext(), id)
if updated == nil {
updated = row
}
if h.historySvc != nil {
_ = h.historySvc.Record(c.UserContext(), updated.HelicopterID, fleethistory.EntityComplaint, updated.ID, fleethistory.ActionNSRCancelled, "NSR cancelled", actor)
}
names := h.resolveComplaintNames(c.UserContext(), []complaint.Complaint{*updated})
signoffs := h.resolveComplaintSignoffs(c.UserContext(), []complaint.Complaint{*updated}, names)
res := h.complaintResource(c.UserContext(), updated, names, signoffs)
res.Attributes.Helicopter = h.complaintHelicopterObject(c.UserContext(), updated.HelicopterID)
return response.Write(c, fiber.StatusOK, responsedto.ComplaintResponse{Data: res})
}
// DeleteComplaint godoc
// @Summary Delete complaint
// @Tags Complaints
// @Produce json
// @Param id path string true "Complaint ID (UUIDv7)"
// @Success 200 {object} jsonapi.Document
// @Failure 404 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/delete/{id} [delete]
func (h *ComplaintHandler) Delete(c *fiber.Ctx) error {
id, err := uuidv7.ParseString(strings.TrimSpace(c.Params("id")))
if err != nil {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidUUID).WithCause(err)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/id"}
return apperrorsx.Write(c, appErr)
}
existing, _ := h.svc.GetByID(c.UserContext(), id)
if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintDeleteFailed))
}
if h.aogRecalc != nil && existing != nil {
_ = h.aogRecalc.RecalculateAOGByHelicopter(c.UserContext(), existing.HelicopterID)
}
return response.Write(c, fiber.StatusOK, map[string]any{"data": map[string]any{"type": "complaint_delete", "attributes": map[string]bool{"deleted": true}}})
}
// ListComplaints godoc
// @Summary List complaints
// @Description Lists complaints grouped by helicopter. Filter by date range on the reported date, by the name of an involved person (reporter / MEL classifier / action signer / NSR decider — i.e. pilot/techniker), and by free-text search on the complaint description.
// @Tags Complaints
// @Produce json
// @Param search query string false "Search complaint description"
// @Param date_from query string false "Reported date lower bound (YYYY-MM-DD)"
// @Param date_to query string false "Reported date upper bound (YYYY-MM-DD)"
// @Param person query string false "Involved person name (pilot/techniker, partial match)"
// @Param helicopter_id query string false "Filter by helicopter ID (UUIDv7)"
// @Param sort query string false "Sort complaints"
// @Param page query int false "Page number" default(1)
// @Param page_size query int false "Page size" default(20)
// @Success 200 {object} responsedto.ComplaintGroupedListResponse
// @Router /api/v1/complaints/get-all [get]
func (h *ComplaintHandler) List(c *fiber.Ctx) (err error) {
if h == nil || h.svc == nil {
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed))
}
defer func() {
if r := recover(); r != nil {
slog.ErrorContext(c.UserContext(), "complaint list panic", "panic", r, "stack", string(debug.Stack()), "path", c.Path())
err = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed).WithCause(nil))
}
}()
page := intOrDefaultFromString(c.Query("page"), 1)
if page < 1 {
page = 1
}
size := intOrDefaultFromString(c.Query("page_size"), 20)
if size < 1 {
size = 20
}
filter, verr := complaintListFilterFromQuery(c)
if verr != nil {
return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*verr})
}
groups, total, err := h.groupedList(c.UserContext(), filter, strings.TrimSpace(c.Query("sort")), 0, 0, nil)
if err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintListFailed))
}
return response.WriteWithMeta(c, fiber.StatusOK, groups, map[string]any{
"page_number": page,
"page_size": size,
"total": total,
})
}
// ListHoldItems godoc
// @Summary List hold items (open MEL-deferred defects)
// @Description Returns the Hold Item List (HIL): complaints that are still OPEN (not yet fixed by an EASA release) AND MEL-deferred (mel_severity > 0). Non-MEL defects (which ground the aircraft immediately) and already-serviced complaints are excluded. Same grouped shape and filters as get-all.
// @Tags Complaints
// @Produce json
// @Param date_from query string false "From date — reported_at >= (YYYY-MM-DD). Legacy 'Von'."
// @Param date_to query string false "To date — reported_at <= (YYYY-MM-DD). Legacy 'bis'."
// @Param person query string false "Pilot/Technician name — matches reporter or action signer. Legacy 'Pilot/Techniker'."
// @Param search query string false "Search complaint description / MEL. Legacy 'Complains'."
// @Param helicopter_id query string false "Helicopter ID (UUIDv7)"
// @Param page query int false "Page number (default 1)"
// @Param page_size query int false "Page size (default 20)"
// @Param sort query string false "Sort expression (e.g. reported_at DESC)"
// @Success 200 {object} responsedto.ComplaintGroupedListResponse
// @Failure 422 {object} jsonapi.ErrorDocument
// @Failure 400 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/hold-items/get-all [get]
func (h *ComplaintHandler) ListHoldItems(c *fiber.Ctx) (err error) {
if h == nil || h.svc == nil {
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed))
}
defer func() {
if r := recover(); r != nil {
slog.ErrorContext(c.UserContext(), "complaint hold-items panic", "panic", r, "stack", string(debug.Stack()), "path", c.Path())
err = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed).WithCause(nil))
}
}()
page := intOrDefaultFromString(c.Query("page"), 1)
if page < 1 {
page = 1
}
size := intOrDefaultFromString(c.Query("page_size"), 20)
if size < 1 {
size = 20
}
filter, verr := complaintListFilterFromQuery(c)
if verr != nil {
return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*verr})
}
filter.HoldItemsOnly = true
groups, total, err := h.groupedList(c.UserContext(), filter, strings.TrimSpace(c.Query("sort")), 0, 0, nil)
if err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintListFailed))
}
return response.WriteWithMeta(c, fiber.StatusOK, groups, map[string]any{
"page_number": page,
"page_size": size,
"total": total,
})
}
// ListHoldItemsDatatable godoc
// @Summary List hold items (open MEL-deferred defects) — datatable
// @Description Datatable variant of the Hold Item List (open + MEL-deferred complaints). Same filters as /hold-items/get-all (date_from, date_to, person, search, helicopter_id) plus DataTables params (start, length, draw).
// @Tags Complaints
// @Produce json
// @Param date_from query string false "From date — reported_at >= (YYYY-MM-DD). Legacy 'Von'."
// @Param date_to query string false "To date — reported_at <= (YYYY-MM-DD). Legacy 'bis'."
// @Param person query string false "Pilot/Technician name — matches reporter or action signer."
// @Param search query string false "Search complaint description / MEL."
// @Param helicopter_id query string false "Helicopter ID (UUIDv7)"
// @Param start query int false "DataTables start offset"
// @Param length query int false "DataTables page length"
// @Param draw query int false "DataTables draw counter"
// @Success 200 {object} responsedto.ComplaintGroupedListResponse
// @Failure 422 {object} jsonapi.ErrorDocument
// @Failure 400 {object} jsonapi.ErrorDocument
// @Router /api/v1/complaints/hold-items/get-all/dt [get]
func (h *ComplaintHandler) ListHoldItemsDatatable(c *fiber.Ctx) (err error) {
if h == nil || h.svc == nil {
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed))
}
defer func() {
if r := recover(); r != nil {
slog.ErrorContext(c.UserContext(), "complaint hold-items datatable panic", "panic", r, "stack", string(debug.Stack()), "path", c.Path())
err = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed).WithCause(nil))
}
}()
start := intOrDefaultFromString(c.Query("start"), 0)
length := intOrDefaultFromString(c.Query("length"), 10)
draw := intOrDefaultFromString(c.Query("draw"), 1)
filter, verr := complaintListFilterFromQuery(c)
if verr != nil {
return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*verr})
}
filter.HoldItemsOnly = true
groups, total, err := h.groupedList(c.UserContext(), filter, strings.TrimSpace(c.Query("sort")), length, start, nil)
if err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintListFailed))
}
return response.WriteWithMeta(c, fiber.StatusOK, groups, map[string]any{
"draw": draw,
"records_total": total,
"records_filtered": total,
})
}
// @Summary List complaints grouped by helicopter (datatable)
// @Tags Complaints
// @Produce json
// @Param search query string false "Search complaint description"
// @Param date_from query string false "Reported date lower bound (YYYY-MM-DD)"
// @Param date_to query string false "Reported date upper bound (YYYY-MM-DD)"
// @Param person query string false "Involved person name (pilot/techniker, partial match)"
// @Param helicopter_id query string false "Filter by helicopter ID (UUIDv7)"
// @Param sort query string false "Sort complaints"
// @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)
// @Success 200 {object} responsedto.ComplaintGroupedDataTableResponse
// @Router /api/v1/complaints/get-all/dt [get]
func (h *ComplaintHandler) ListDatatable(c *fiber.Ctx) (err error) {
if h == nil || h.svc == nil {
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed))
}
defer func() {
if r := recover(); r != nil {
slog.ErrorContext(c.UserContext(), "complaint datatable panic", "panic", r, "stack", string(debug.Stack()), "path", c.Path())
err = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintListFailed).WithCause(nil))
}
}()
start := intOrDefaultFromString(c.Query("start"), 0)
length := intOrDefaultFromString(c.Query("length"), 10)
draw := intOrDefaultFromString(c.Query("draw"), 1)
filter, verr := complaintListFilterFromQuery(c)
if verr != nil {
return writeComplaintErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*verr})
}
groups, total, err := h.groupedList(c.UserContext(), filter, strings.TrimSpace(c.Query("sort")), length, start, nil)
if err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintListFailed))
}
return response.WriteWithMeta(c, fiber.StatusOK, groups, map[string]any{
"draw": draw,
"records_total": total,
"records_filtered": total,
})
}
// @Summary List complaints by helicopter
// @Tags Complaints
// @Produce json
// @Param helicopter_id path string true "Helicopter ID"
// @Success 200 {object} responsedto.ComplaintGroupedListResponse
// @Router /api/v1/complaints/get-by-helicopter/{helicopter_id} [get]
func (h *ComplaintHandler) ListByHelicopter(c *fiber.Ctx) (err error) {
if h == nil || h.svc == nil {
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintGroupedListFailed))
}
defer func() {
if r := recover(); r != nil {
slog.ErrorContext(c.UserContext(), "complaint list by helicopter panic", "panic", r, "stack", string(debug.Stack()), "path", c.Path())
err = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrComplaintGroupedListFailed).WithCause(nil))
}
}()
helicopterID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("helicopter_id")))
if err != nil {
appErr := apperrorsx.New(apperrorsx.ErrComplaintInvalidHelicopterID).WithCause(err)
appErr.Source = &jsonapi.ErrorSource{Pointer: "/path/helicopter_id"}
return apperrorsx.Write(c, appErr)
}
page := intOrDefaultFromString(c.Query("page"), 1)
if page < 1 {
page = 1
}
size := intOrDefaultFromString(c.Query("page_size"), 20)
if size < 1 {
size = 20
}
groups, total, err := h.groupedList(c.UserContext(), complaint.ListFilter{Search: strings.TrimSpace(c.Query("filter"))}, strings.TrimSpace(c.Query("sort")), 0, 0, helicopterID)
if err != nil {
return apperrorsx.Write(c, mapComplaintServiceErrorWithFallback(err, apperrorsx.ErrComplaintGroupedListFailed))
}
return response.WriteWithMeta(c, fiber.StatusOK, groups, map[string]any{
"page_number": page,
"page_size": size,
"total": total,
})
}
// complaintListFilterFromQuery builds the list filter from query params, supporting
// plain query params (search, date_from, date_to, person, helicopter_id), with the
// legacy `filter` key kept as an alias for search.
// Returns a 422 error object when a date param is not YYYY-MM-DD.
func complaintListFilterFromQuery(c *fiber.Ctx) (complaint.ListFilter, *jsonapi.ErrorObject) {
q := func(keys ...string) string {
for _, k := range keys {
if v := strings.TrimSpace(c.Query(k)); v != "" {
return v
}
}
return ""
}
f := complaint.ListFilter{
Search: q("search", "filter"),
DateFrom: q("date_from"),
DateTo: q("date_to"),
Person: q("person"),
}
if heliRaw := q("helicopter_id"); heliRaw != "" {
helicopterID, err := uuidv7.ParseString(heliRaw)
if err != nil {
return f, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "helicopter_id is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/helicopter_id"},
}
}
f.HelicopterID = helicopterID
}
if flightRaw := q("flight_id"); flightRaw != "" {
flightID, err := uuidv7.ParseString(flightRaw)
if err != nil {
return f, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "flight_id is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/flight_id"},
}
}
f.FlightID = flightID
}
for _, d := range []struct {
field string
value string
}{{"date_from", f.DateFrom}, {"date_to", f.DateTo}} {
if d.value == "" {
continue
}
if _, err := time.Parse("2006-01-02", d.value); err != nil {
return f, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: d.field + " must be YYYY-MM-DD",
Source: &jsonapi.ErrorSource{Pointer: "/" + d.field},
}
}
}
return f, nil
}
func (h *ComplaintHandler) groupedList(ctx context.Context, filter complaint.ListFilter, sort string, limit, offset int, helicopterID []byte) ([]responsedto.ComplaintHelicopterGroupResource, int64, error) {
if h == nil || h.svc == nil {
return nil, 0, apperrorsx.New(apperrorsx.ErrComplaintListFailed)
}
if len(helicopterID) > 0 {
rows, total, err := h.svc.ListByHelicopter(ctx, helicopterID, limit, offset)
if err != nil {
return nil, 0, err
}
return h.groupComplaints(ctx, rows), total, nil
}
rows, total, err := h.svc.List(ctx, filter, sort, limit, offset)
if err != nil {
return nil, 0, err
}
return h.groupComplaints(ctx, rows), total, nil
}
func (h *ComplaintHandler) groupComplaints(ctx context.Context, rows []complaint.Complaint) []responsedto.ComplaintHelicopterGroupResource {
if len(rows) == 0 {
return []responsedto.ComplaintHelicopterGroupResource{}
}
names := h.resolveComplaintNames(ctx, rows)
signoffs := h.resolveComplaintSignoffs(ctx, rows, names)
grouped := map[string]*responsedto.ComplaintHelicopterGroupResource{}
groupRows := map[string][]complaint.Complaint{}
order := make([]string, 0)
for i := range rows {
row := rows[i]
key := complaintHelicopterKey(&row)
g, ok := grouped[key]
if !ok {
g = &responsedto.ComplaintHelicopterGroupResource{
Type: "complaint_helicopter_group",
Attributes: responsedto.ComplaintHelicopterGroupAttributes{
Complaints: []responsedto.ComplaintResource{},
},
}
grouped[key] = g
order = append(order, key)
}
res := h.complaintResource(ctx, &row, names, signoffs)
g.Attributes.Complaints = append(g.Attributes.Complaints, res)
groupRows[key] = append(groupRows[key], row)
}
inUse := h.helicopterInUse(ctx, order)
helIDs := make([][]byte, 0, len(order))
for _, key := range order {
if len(key) == 16 {
helIDs = append(helIDs, []byte(key))
}
}
mcfPending := mcfPendingHelicopterSet(ctx, h.mcfSvc, helIDs)
for _, key := range order {
grouped[key].Attributes.Helicopter = h.complaintHelicopterResource(ctx, []byte(key), groupRows[key], inUse[key], mcfPending[key])
}
out := make([]responsedto.ComplaintHelicopterGroupResource, 0, len(order))
for _, key := range order {
out = append(out, *grouped[key])
}
// Order the helicopter groups by operational status: MCF, AOG, Booked, Available.
sort.SliceStable(out, func(i, j int) bool {
return helicopter.StatusRank(complaintGroupStatus(out[i])) < helicopter.StatusRank(complaintGroupStatus(out[j]))
})
return out
}
func complaintGroupStatus(g responsedto.ComplaintHelicopterGroupResource) string {
if g.Attributes.Helicopter != nil {
return g.Attributes.Helicopter.Status
}
return ""
}
// resolveComplaintNames resolves reporter display names for the given complaints,
// keyed by the raw user-id bytes (as string). Returns an empty map on any failure
// so callers can index without nil checks.
func (h *ComplaintHandler) resolveComplaintNames(ctx context.Context, rows []complaint.Complaint) map[string]string {
if h == nil || h.svc == nil || len(rows) == 0 {
return map[string]string{}
}
if names, err := h.svc.ResolveUserNames(ctx, rows); err == nil && names != nil {
return names
}
return map[string]string{}
}
func (h *ComplaintHandler) resolveComplaintSignoffs(ctx context.Context, rows []complaint.Complaint, names map[string]string) map[string]*actionsignoff.ActionSignoff {
if h == nil {
return map[string]*actionsignoff.ActionSignoff{}
}
return resolveSignoffsByComplaint(ctx, h.signoffSvc, rows, names)
}
// resolveSignoffsByComplaint batch-loads the corrective-action sign-offs for the given
// complaints (keyed by complaint id bytes as string) and folds each signer's display
// name into names so the resource mappers can render it. Returns an empty map on any
// failure or when svc is nil (sign state simply renders as unsigned).
func resolveSignoffsByComplaint(ctx context.Context, svc complaintSignoffLookup, rows []complaint.Complaint, names map[string]string) map[string]*actionsignoff.ActionSignoff {
if svc == nil || len(rows) == 0 {
return map[string]*actionsignoff.ActionSignoff{}
}
ids := make([][]byte, 0, len(rows))
for i := range rows {
if len(rows[i].ID) == 16 {
ids = append(ids, rows[i].ID)
}
}
m, err := svc.GetByComplaintIDs(ctx, ids)
if err != nil || m == nil {
return map[string]*actionsignoff.ActionSignoff{}
}
for _, so := range m {
if so == nil || len(so.SignedBy) == 0 {
continue
}
if _, ok := names[string(so.SignedBy)]; !ok {
if n := userctx.GetDisplayName(ctx, so.SignedBy); n != "" {
names[string(so.SignedBy)] = n
}
}
}
return m
}
// helicopterInUse batch-resolves the in-use (booked) flag for the helicopter group
// keys (raw id bytes as string). Non-uuid keys (e.g. "unknown") are skipped.
func (h *ComplaintHandler) helicopterInUse(ctx context.Context, keys []string) map[string]bool {
if h == nil || h.helicopterSvc == nil {
return map[string]bool{}
}
ids := make([][]byte, 0, len(keys))
for _, k := range keys {
if len(k) == 16 {
ids = append(ids, []byte(k))
}
}
if len(ids) == 0 {
return map[string]bool{}
}
if inUse, err := h.helicopterSvc.InUseByAircraftIDs(ctx, ids); err == nil && inUse != nil {
return inUse
}
return map[string]bool{}
}
// complaintHelicopterResource builds the embedded helicopter object for a group:
// full master data (designation/identifier/type), the derived operational status,
// and complaint-derived status reasons. Falls back to an id-only object when the
// helicopter service is unavailable or the lookup fails.
func (h *ComplaintHandler) complaintHelicopterResource(ctx context.Context, helicopterID []byte, complaints []complaint.Complaint, inUse bool, mcfPending bool) *shareddto.FleetStatusHelicopter {
heliID := idString(helicopterID)
if heliID == "" {
return nil
}
if h == nil || h.helicopterSvc == nil {
return &shareddto.FleetStatusHelicopter{ID: heliID}
}
heli, err := h.helicopterSvc.GetByID(ctx, helicopterID)
if err != nil || heli == nil {
return &shareddto.FleetStatusHelicopter{ID: heliID}
}
now := time.Now().UTC()
status := heli.DeriveStatus(helicopter.StatusInput{
IsBook: inUse,
IsMCF: mcfPending,
ComplaintGrounded: anyGrounding(complaints, now),
})
res := fleetStatusHelicopterResource(heli, status)
if res != nil {
res.StatusReasons = manualAOGReasons(heli)
}
return res
}
// complaintHelicopterObject builds the full helicopter object for a single complaint
// response. It pulls the helicopter's open complaints so the derived status (AOG/MCF/
// booked/available) reflects the whole aircraft, not just this one complaint.
func (h *ComplaintHandler) complaintHelicopterObject(ctx context.Context, helicopterID []byte) *shareddto.FleetStatusHelicopter {
if h == nil || len(helicopterID) != 16 {
return nil
}
var complaints []complaint.Complaint
if h.svc != nil {
if rows, _, err := h.svc.ListByHelicopter(ctx, helicopterID, 100, 0); err == nil {
complaints = rows
}
}
inUse := h.helicopterInUse(ctx, []string{string(helicopterID)})[string(helicopterID)]
mcfPending := mcfPendingHelicopterSet(ctx, h.mcfSvc, [][]byte{helicopterID})[string(helicopterID)]
return h.complaintHelicopterResource(ctx, helicopterID, complaints, inUse, mcfPending)
}
// recordComplaintFiled logs the filing event (and an NSR event when filed as NSR).
func (h *ComplaintHandler) recordComplaintFiled(c *fiber.Ctx, row *complaint.Complaint) {
if h.historySvc == nil || row == nil {
return
}
msg := "Complaint filed: " + row.Description
if lbl := complaintSeverityLabel(row.MELSeverity); lbl != "" {
msg += " (MEL " + lbl + ")"
}
actor := actorUserID(c)
_ = h.historySvc.Record(c.UserContext(), row.HelicopterID, fleethistory.EntityComplaint, row.ID, fleethistory.ActionComplaintFiled, msg, actor)
if row.ActionTaken != nil {
_ = h.historySvc.Record(c.UserContext(), row.HelicopterID, fleethistory.EntityComplaint, row.ID, fleethistory.ActionActionTaken, "Action taken — "+*row.ActionTaken, actor)
}
}
// recordComplaintUpdate logs MEL classification, NSR done/cancel, and action-taken
// events based on which attributes were present in the update request.
func (h *ComplaintHandler) recordComplaintUpdate(c *fiber.Ctx, attrs requestdto.ComplaintUpdateAttributes, row *complaint.Complaint) {
if h.historySvc == nil || row == nil {
return
}
actor := actorUserID(c)
if attrs.MELSeverity != nil {
msg := "MEL classified"
if lbl := complaintSeverityLabel(*attrs.MELSeverity); lbl != "" {
msg = "MEL classified as " + lbl
}
_ = h.historySvc.Record(c.UserContext(), row.HelicopterID, fleethistory.EntityComplaint, row.ID, fleethistory.ActionMELClassified, msg, actor)
}
}
func nsrLogMessage(row *complaint.Complaint) string {
if row != nil && row.NSRReason != nil && strings.TrimSpace(*row.NSRReason) != "" {
return "NSR done — " + strings.TrimSpace(*row.NSRReason)
}
return "NSR done"
}
// anyGrounding reports whether any complaint currently grounds the aircraft (AOG):
// a non-MEL defect (immediately) or a MEL past its grace deadline. EASA-fixed
// complaints (signed set) are skipped.
func anyGrounding(rows []complaint.Complaint, now time.Time) bool {
for i := range rows {
if rows[i].IsGrounding(now) {
return true
}
}
return false
}
// complaintGroundingLister fetches open grounding-candidate complaints (non-MEL, or MEL
// not yet released/fixed) for a set of helicopters so any view can derive read-time
// AOG without coupling to the full complaint service.
type complaintGroundingLister interface {
ActiveGroundingByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) ([]complaint.Complaint, error)
}
// groundingHelicopterSet returns the set of helicopter ids (raw-bytes string key) that
// have at least one complaint currently grounding the aircraft: a non-MEL defect
// (immediately) or a MEL past its grace deadline.
func groundingHelicopterSet(ctx context.Context, svc complaintGroundingLister, ids [][]byte, now time.Time) map[string]bool {
out := map[string]bool{}
if svc == nil || len(ids) == 0 {
return out
}
rows, err := svc.ActiveGroundingByHelicopterIDs(ctx, ids)
if err != nil {
return out
}
for i := range rows {
if rows[i].IsGrounding(now) {
out[string(rows[i].HelicopterID)] = true
}
}
return out
}
// groundedHelicopterIDs returns the ids of every helicopter currently grounded by an
// open complaint (per IsGrounding). It lets status filters be AOG-aware, since
// complaint grounding is computed at read time and not persisted in air_on_ground.
func groundedHelicopterIDs(ctx context.Context, svc complaintGroundingLister, now time.Time) [][]byte {
if svc == nil {
return nil
}
rows, err := svc.ActiveGroundingByHelicopterIDs(ctx, nil)
if err != nil {
return nil
}
seen := make(map[string]struct{}, len(rows))
out := make([][]byte, 0, len(rows))
for i := range rows {
if !rows[i].IsGrounding(now) {
continue
}
key := string(rows[i].HelicopterID)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, append([]byte(nil), rows[i].HelicopterID...))
}
return out
}
// complaintGroundingReason builds a user-facing grounding reason, distinguishing an
// expired MEL grace period (with its A/B/C/D class) from an immediate non-MEL defect.
func complaintGroundingReason(c complaint.Complaint) string {
if c.MELSeverity > complaint.MELSeverityNonMEL {
return "Grounded due to an open complaint — MEL " + shareddto.MELLabel(c.MELSeverity) + " grace period expired"
}
return "Grounded due to an open non-MEL complaint"
}
// groundingReasonHelicopterSet maps each grounded helicopter id (raw-bytes string key)
// to a human-readable grounding reason. An absent key means the helicopter is not grounded.
func groundingReasonHelicopterSet(ctx context.Context, svc complaintGroundingLister, ids [][]byte, now time.Time) map[string]string {
out := map[string]string{}
if svc == nil || len(ids) == 0 {
return out
}
rows, err := svc.ActiveGroundingByHelicopterIDs(ctx, ids)
if err != nil {
return out
}
for i := range rows {
if !rows[i].IsGrounding(now) {
continue
}
key := string(rows[i].HelicopterID)
if _, exists := out[key]; !exists {
out[key] = complaintGroundingReason(rows[i])
}
}
return out
}
// manualAOGReasons returns status reasons for the embedded helicopter. Automatic
// grounding (expired MEL) is conveyed by the derived status alone; only a manual
// AOG carries an explanatory reason.
func manualAOGReasons(heli *helicopter.Helicopter) []shareddto.FleetStatusHelicopterReason {
if heli == nil || !heli.AirOnGround || heli.AOGSource != helicopter.AOGSourceManual {
return nil
}
detail := strings.TrimSpace(heli.AOGReason)
if detail == "" {
detail = "Manual AOG"
}
return []shareddto.FleetStatusHelicopterReason{{Code: shareddto.ReasonCodeManualAOG, Detail: detail}}
}
// mapComplaintServiceErrorWithFallback translates a raw service/repo error into a
// dedicated complaint AppError. Specific mapped codes (not found, relation delete,
// invalid helicopter reference, invalid payload) pass through with their proper HTTP
// status; anything else collapses to the operation-specific fallback (create/update/...).
func mapComplaintServiceErrorWithFallback(err error, fallback apperrorsx.Def) *apperrorsx.AppError {
mapped := apperrorsx.Translate(apperrorsx.ModuleComplaint, err)
if mapped == nil {
return apperrorsx.New(fallback).WithCause(err)
}
switch mapped.Code {
case apperrorsx.CodeComplaintNotFound,
apperrorsx.CodeComplaintRelationDelete,
apperrorsx.CodeComplaintInvalidHelicopterID,
apperrorsx.CodeComplaintInvalidPayload:
return mapped
default:
return apperrorsx.New(fallback).WithCause(err)
}
}
func writeComplaintErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error {
return jsonapi.WriteErrors(c, status, errs)
}
func intOrDefaultFromString(raw string, def int) int {
if v, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil {
return v
}
return def
}
func emptyToNilString(v *string) *string {
if v == nil {
return nil
}
if strings.TrimSpace(*v) == "" {
return nil
}
out := strings.TrimSpace(*v)
return &out
}