1342 lines
55 KiB
Go
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
|
|
}
|