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 }