package handlers import ( "context" "errors" "fmt" "log/slog" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" filemanager "wucher/internal/domain/file_manager" fleethistory "wucher/internal/domain/fleet_history" fleetstatus "wucher/internal/domain/fleet_status" "wucher/internal/domain/helicopter" appservice "wucher/internal/service" sharedconst "wucher/internal/shared/const" "wucher/internal/shared/pkg/apperrorsx" "wucher/internal/shared/pkg/attachmentresolver" "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" "gorm.io/gorm" ) const ( helicopterPhotoAttachmentRefType = "helicopter_photo" helicopterPhotoAttachmentCategory = "photo" ) type helicopterPINVerifier interface { ConsumeSecurityPINActionToken(ctx context.Context, userID []byte, action, token string) error } type helicopterPINSessionVerifier interface { AccessCookieName() string ParseAccessTokenSessionID(ctx context.Context, accessToken string) (string, error) ConsumeSecurityPINActionTokenInSession(ctx context.Context, userID []byte, action, token, sessionID string) error } type helicopterDetailedWriter interface { CreateDetailed(context.Context, appservice.HelicopterCreateInput) (*appservice.HelicopterWriteResult, error) UpdateDetailed(context.Context, appservice.HelicopterUpdateInput) (*appservice.HelicopterWriteResult, error) } // helicopterFleetStatusProvider exposes just the fleet status lookup the // helicopter handler needs to attach maintenance schedules to responses. type helicopterFleetStatusProvider interface { LatestByHelicopterIDs(ctx context.Context, helicopterIDs [][]byte) (map[string]*fleetstatus.FleetStatus, error) } type HelicopterHandler struct { svc helicopter.Service fileManager filemanager.Service storage fileManagerDownloadURLStorage reserveAc helicopterLastDailyInspectionProvider fleetStatus helicopterFleetStatusProvider pinVerifier helicopterPINVerifier complaintSvc complaintGroundingLister mcfSvc mcfPendingChecker historySvc fleetHistoryRecorder validate *validators.Validator } func (h *HelicopterHandler) WithHistoryService(svc fleetHistoryRecorder) *HelicopterHandler { h.historySvc = svc return h } // recordManualAOG logs a manual AOG set/clear when the request explicitly toggled // the persisted air_on_ground flag. aog is nil when the request didn't touch it. func (h *HelicopterHandler) recordManualAOG(c *fiber.Ctx, row *helicopter.Helicopter, aog *bool) { if h.historySvc == nil || row == nil || aog == nil { return } action := fleethistory.ActionAOGCleared msg := "AOG cleared (manual)" if *aog { action = fleethistory.ActionAOGSet msg = "AOG set (manual)" if r := strings.TrimSpace(row.AOGReason); r != "" { msg = "AOG set (manual) — " + r } } _ = h.historySvc.Record(c.UserContext(), row.ID, fleethistory.EntityHelicopter, row.ID, action, msg, actorUserID(c)) } type helicopterLastDailyInspectionProvider interface { GetLastDailyInspectionSummaryByHelicopterID(ctx context.Context, helicopterID []byte) (*shareddto.LastDailyInspectionSummary, error) } func NewHelicopterHandler(svc helicopter.Service) *HelicopterHandler { return &HelicopterHandler{ svc: svc, validate: validators.New(), } } func (h *HelicopterHandler) WithFileManagerService(fileManagerSvc filemanager.Service) *HelicopterHandler { h.fileManager = fileManagerSvc return h } func (h *HelicopterHandler) WithFileStorage(storage fileManagerDownloadURLStorage) *HelicopterHandler { h.storage = storage return h } func (h *HelicopterHandler) WithReserveAcService(reserveAcSvc helicopterLastDailyInspectionProvider) *HelicopterHandler { h.reserveAc = reserveAcSvc return h } func (h *HelicopterHandler) WithPINVerifier(verifier helicopterPINVerifier) *HelicopterHandler { h.pinVerifier = verifier return h } func (h *HelicopterHandler) WithFleetStatusService(fleetStatusSvc helicopterFleetStatusProvider) *HelicopterHandler { h.fleetStatus = fleetStatusSvc return h } func (h *HelicopterHandler) WithMCFService(svc mcfPendingChecker) *HelicopterHandler { h.mcfSvc = svc return h } func (h *HelicopterHandler) WithComplaintService(svc complaintGroundingLister) *HelicopterHandler { h.complaintSvc = svc return h } // latestFleetStatusByHelicopter returns, per helicopter id, that helicopter's // latest fleet status (nil map when no fleet status service is wired). Indexing a // missing/nil entry yields nil, which callers treat as "no fleet status". func (h *HelicopterHandler) latestFleetStatusByHelicopter(ctx context.Context, ids [][]byte) map[string]*fleetstatus.FleetStatus { if h.fleetStatus == nil || len(ids) == 0 { return nil } latest, err := h.fleetStatus.LatestByHelicopterIDs(ctx, ids) if err != nil { return nil } return latest } // applyHelicopterFleetStatus attaches the helicopter's latest fleet status data // to the resource: the full maintenance schedule set and the MCF-aware // operational status. Without a fleet status the default empty schedules are // kept and status is recomputed with MCF=false. func applyHelicopterFleetStatus(res *responsedto.HelicopterResource, row *helicopter.Helicopter, isInUse bool, groundReason string, mcfPending bool, fs *fleetstatus.FleetStatus) { grounded := strings.TrimSpace(groundReason) != "" if fs != nil { res.Attributes.MaintenanceSchedules = fleetStatusMaintenanceSchedules(fs.MaintenanceSchedules) } res.Attributes.Status = row.DeriveStatus(helicopter.StatusInput{IsBook: isInUse, IsMCF: mcfPending, ComplaintGrounded: grounded}) if res.Attributes.Status == helicopter.StatusAOG { res.Attributes.AOG = true // A grounded helicopter is inactive, mirroring EnforceAOGInactive. Read-time // grounding (open complaint / expired MEL) is not persisted in air_on_ground, // so reflect the inactive state in the response regardless of the stored flag. res.Attributes.IsActive = false if !row.AirOnGround { res.Attributes.AOGReason = groundReason } } } // helicopterResourceWithSchedules builds a single helicopter resource enriched // with its latest fleet status (maintenance schedules + MCF-aware status). func (h *HelicopterHandler) helicopterResourceWithSchedules(ctx context.Context, row *helicopter.Helicopter, isInUse bool) responsedto.HelicopterResource { res := h.helicopterResource(ctx, row, isInUse) fs := h.latestFleetStatusByHelicopter(ctx, [][]byte{row.ID})[string(row.ID)] groundReason := groundingReasonHelicopterSet(ctx, h.complaintSvc, [][]byte{row.ID}, time.Now().UTC())[string(row.ID)] mcfPending := mcfPendingHelicopterSet(ctx, h.mcfSvc, [][]byte{row.ID})[string(row.ID)] applyHelicopterFleetStatus(&res, row, isInUse, groundReason, mcfPending, fs) return res } func (h *HelicopterHandler) writeHelicopterWriteServiceError(c *fiber.Ctx, err error) error { var writeErr *appservice.HelicopterWriteError if !errors.As(err, &writeErr) { return h.writeHelicopterServiceError(c, "HelicopterHandler.CreateDetailed", err) } switch writeErr.Status { case fiber.StatusNotFound, fiber.StatusConflict, fiber.StatusUnprocessableEntity, fiber.StatusAccepted, fiber.StatusUnauthorized: errs := []jsonapi.ErrorObject{{ Title: writeErr.Title, Detail: writeErr.Detail, }} if strings.TrimSpace(writeErr.Pointer) != "" { errs[0].Source = &jsonapi.ErrorSource{Pointer: writeErr.Pointer} } return writeHelicopterErrors(c, writeErr.Status, errs) default: return h.writeHelicopterServiceError(c, "HelicopterHandler.CreateDetailed", err) } } func (h *HelicopterHandler) writeHelicopterServiceError(c *fiber.Ctx, operation string, err error) error { mapped := apperrorsx.Translate(apperrorsx.ModuleHelicopter, err) if mapped != nil && mapped.Code != apperrorsx.CodeInternalServerError { logHelicopterServiceError(c, operation, mapped.Status, mapped.Code, mapped.ErrorCode, err) return apperrorsx.Write(c, mapped) } logHelicopterServiceError(c, operation, fiber.StatusInternalServerError, apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode, err) appErr := apperrorsx.New(apperrorsx.ErrInternal) appErr.Message = "an internal error occurred" return apperrorsx.Write(c, appErr) } func (h *HelicopterHandler) createHelicopterWithService(c *fiber.Ctx, svc helicopterDetailedWriter) bool { var req requestdto.HelicopterCreateRequest if err := c.BodyParser(&req); err != nil { _ = apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrHelicopterInvalidJSON)) return true } if errs := h.validate.ValidateStruct(req); errs != nil { _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, errs) return true } designation := strings.TrimSpace(req.Data.Attributes.Designation) identifierInput := normalizeHelicopterIdentifier(req.Data.Attributes.Identifier) heliType := strings.TrimSpace(req.Data.Attributes.Type) if designation == "" || identifierInput == "" || heliType == "" { _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "designation, identifier, and type are required", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"}, }}) return true } modelID := uuidv7.MustBytes() fotoAttachmentID := []byte(nil) if req.Data.Attributes.FileUUID != nil { resolved, handled := h.resolveFotoAttachmentID(c, modelID, req.Data.Attributes.FileUUID, "Create failed") if handled { return true } fotoAttachmentID = resolved } var accessToken string if sessionVerifier, ok := h.pinVerifier.(helicopterPINSessionVerifier); ok { for _, candidate := range appservice.CookieValues(c.Get(fiber.HeaderCookie), sessionVerifier.AccessCookieName()) { if _, err := sessionVerifier.ParseAccessTokenSessionID(c.UserContext(), candidate); err == nil { accessToken = candidate break } } } res, err := svc.CreateDetailed(c.UserContext(), appservice.HelicopterCreateInput{ ID: modelID, Designation: designation, Identifier: identifierInput, Type: heliType, SortKey: cloneIntPointer(req.Data.Attributes.SortKey), Engine1: strings.TrimSpace(stringOrDefault(req.Data.Attributes.Engine1, "")), Engine2: strings.TrimSpace(stringOrDefault(req.Data.Attributes.Engine2, "")), Consumption: floatOrDefault(req.Data.Attributes.Consumption, 0), NR1: boolOrDefault(req.Data.Attributes.NR1, false), NR2: boolOrDefault(req.Data.Attributes.NR2, false), LH: boolOrDefault(req.Data.Attributes.LH, false), RH: boolOrDefault(req.Data.Attributes.RH, false), MGB: boolOrDefault(req.Data.Attributes.MGB, false), IGB: boolOrDefault(req.Data.Attributes.IGB, false), TGB: boolOrDefault(req.Data.Attributes.TGB, false), UTCOffset: intOrDefault(req.Data.Attributes.UTCOffset, 0), FotoAttachmentID: fotoAttachmentID, Dry: boolOrDefault(req.Data.Attributes.Dry, false), AirOnGround: boolOrDefault(req.Data.Attributes.AOG, false), AOGReason: strings.TrimSpace(stringOrDefault(req.Data.Attributes.AOGReason, "")), Note: strings.TrimSpace(stringOrDefault(req.Data.Attributes.Note, "")), IsActive: boolOrDefault(req.Data.Attributes.IsActive, true), ActorUserID: actorUserID(c), PINVerifier: h.pinVerifier, PINToken: strings.TrimSpace(c.Get(pinVerificationHeader)), AccessToken: accessToken, }) if err != nil { _ = h.writeHelicopterWriteServiceError(c, err) return true } if len(res.PreviousFotoAttachmentID) == 16 { _ = syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, res.PreviousFotoAttachmentID, res.Row.FotoAttachmentID, actorUserID(c)) } if res.Row != nil && res.Row.AirOnGround { on := true h.recordManualAOG(c, res.Row, &on) } _ = response.Write(c, fiber.StatusCreated, h.helicopterResourceWithSchedules(c.UserContext(), res.Row, res.IsInUse)) return true } func (h *HelicopterHandler) updateHelicopterWithService(c *fiber.Ctx, svc helicopterDetailedWriter) bool { idStr := strings.TrimSpace(c.Params("id")) id, err := uuidv7.ParseString(idStr) if err != nil { _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) return true } var req requestdto.HelicopterUpdateRequest if err := c.BodyParser(&req); err != nil { _ = writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) return true } if errs := h.validate.ValidateStruct(req); errs != nil { _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, errs) return true } if strings.TrimSpace(req.Data.ID) == "" { _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is required", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) return true } if req.Data.ID != idStr { _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id does not match path id", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) return true } attrs := req.Data.Attributes if attrs.Designation == nil && attrs.Identifier == nil && attrs.Type == nil && !attrs.SortKey.Set && attrs.Engine1 == nil && attrs.Engine2 == nil && attrs.Consumption == nil && attrs.NR1 == nil && attrs.NR2 == nil && attrs.LH == nil && attrs.RH == nil && attrs.MGB == nil && attrs.IGB == nil && attrs.TGB == nil && attrs.UTCOffset == nil && attrs.FileUUID == nil && attrs.Dry == nil && attrs.AOG == nil && attrs.AOGReason == nil && attrs.Note == nil && attrs.IsActive == nil { _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "at least one attribute must be provided", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"}, }}) return true } var fotoAttachmentID []byte fotoAttachmentSet := false if attrs.FileUUID != nil { fotoAttachmentSet = true if hasBlankOptionalString(attrs.FileUUID) { fotoAttachmentID = nil } else { resolved, handled := h.resolveExistingFotoAttachmentOrFileID(c, id, attrs.FileUUID, "Update failed") if handled { return true } fotoAttachmentID = resolved } } var accessToken string if sessionVerifier, ok := h.pinVerifier.(helicopterPINSessionVerifier); ok { for _, candidate := range appservice.CookieValues(c.Get(fiber.HeaderCookie), sessionVerifier.AccessCookieName()) { if _, err := sessionVerifier.ParseAccessTokenSessionID(c.UserContext(), candidate); err == nil { accessToken = candidate break } } } sortKeyPatch := helicopter.SortKeyPatch{Set: attrs.SortKey.Set} if attrs.SortKey.Set && attrs.SortKey.Valid { v := attrs.SortKey.Value sortKeyPatch.Value = &v } res, err := svc.UpdateDetailed(c.UserContext(), appservice.HelicopterUpdateInput{ ID: id, Designation: attrs.Designation, Identifier: attrs.Identifier, Type: attrs.Type, SortKey: sortKeyPatch, Engine1: attrs.Engine1, Engine2: attrs.Engine2, Consumption: attrs.Consumption, NR1: attrs.NR1, NR2: attrs.NR2, LH: attrs.LH, RH: attrs.RH, MGB: attrs.MGB, IGB: attrs.IGB, TGB: attrs.TGB, UTCOffset: attrs.UTCOffset, FotoAttachmentID: fotoAttachmentID, FotoAttachmentSet: fotoAttachmentSet, Dry: attrs.Dry, AirOnGround: attrs.AOG, AOGReason: attrs.AOGReason, Note: attrs.Note, IsActive: attrs.IsActive, ActorUserID: actorUserID(c), PINVerifier: h.pinVerifier, PINToken: strings.TrimSpace(c.Get(pinVerificationHeader)), AccessToken: accessToken, }) if err != nil { _ = h.writeHelicopterWriteServiceError(c, err) return true } if err := syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, res.PreviousFotoAttachmentID, res.Row.FotoAttachmentID, actorUserID(c)); err != nil { _ = writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Update failed", Detail: safeInternalDetail(), }}) return true } h.recordManualAOG(c, res.Row, attrs.AOG) _ = response.Write(c, fiber.StatusOK, h.helicopterResourceWithSchedules(c.UserContext(), res.Row, res.IsInUse)) return true } // CreateHelicopter godoc // @Summary Create helicopter // @Description Create a helicopter configuration row. // @Tags Facility - Helicopters // @Accept json // @Produce json // @Param request body requestdto.HelicopterCreateRequest true "JSON:API Helicopter create request" // @Success 201 {object} responsedto.HelicopterResponse // @Failure 422 {object} jsonapi.ErrorDocument // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopters/create [post] func (h *HelicopterHandler) Create(c *fiber.Ctx) error { svc, ok := h.svc.(helicopterDetailedWriter) if !ok { return writeHelicopterErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{ Status: "500", Title: "Create failed", Detail: "helicopter service is not configured", }}) } if h.createHelicopterWithService(c, svc) { return nil } var req requestdto.HelicopterCreateRequest if err := c.BodyParser(&req); err != nil { return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrHelicopterInvalidJSON)) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, errs) } designation := strings.TrimSpace(req.Data.Attributes.Designation) identifierInput := normalizeHelicopterIdentifier(req.Data.Attributes.Identifier) heliType := strings.TrimSpace(req.Data.Attributes.Type) if designation == "" || identifierInput == "" || heliType == "" { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "designation, identifier, and type are required", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"}, }}) } exists, err := h.svc.ExistsByIdentifier(c.UserContext(), identifierInput, nil) if err != nil { return h.writeHelicopterServiceError(c, "HelicopterHandler.Create.ExistsByIdentifier", err) } if exists { return writeHelicopterErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{ Status: "409", Title: "Conflict", Detail: "Serial number already exists", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/identifier"}, }}) } model := &helicopter.Helicopter{ ID: uuidv7.MustBytes(), Designation: designation, Identifier: identifierInput, ReportSequence: 0, Type: heliType, SortKey: cloneIntPointer(req.Data.Attributes.SortKey), Engine1: strings.TrimSpace(stringOrDefault(req.Data.Attributes.Engine1, "")), Engine2: strings.TrimSpace(stringOrDefault(req.Data.Attributes.Engine2, "")), Consumption: floatOrDefault(req.Data.Attributes.Consumption, 0), NR1: boolOrDefault(req.Data.Attributes.NR1, false), NR2: boolOrDefault(req.Data.Attributes.NR2, false), LH: boolOrDefault(req.Data.Attributes.LH, false), RH: boolOrDefault(req.Data.Attributes.RH, false), MGB: boolOrDefault(req.Data.Attributes.MGB, false), IGB: boolOrDefault(req.Data.Attributes.IGB, false), TGB: boolOrDefault(req.Data.Attributes.TGB, false), UTCOffset: intOrDefault(req.Data.Attributes.UTCOffset, 0), FotoAttachmentID: nil, Dry: boolOrDefault(req.Data.Attributes.Dry, false), AirOnGround: boolOrDefault(req.Data.Attributes.AOG, false), AOGReason: strings.TrimSpace(stringOrDefault(req.Data.Attributes.AOGReason, "")), Note: strings.TrimSpace(stringOrDefault(req.Data.Attributes.Note, "")), IsActive: boolOrDefault(req.Data.Attributes.IsActive, true), CreatedBy: actorUserID(c), UpdatedBy: actorUserID(c), } if errObj := validateAOGReason(model.AirOnGround, model.AOGReason); errObj != nil { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj}) } if model.AirOnGround { if h.requireAOGPINVerification(c) { return nil } } if req.Data.Attributes.FileUUID != nil { fotoAttachmentID, handled := h.resolveFotoAttachmentID(c, model.ID, req.Data.Attributes.FileUUID, "Create failed") if handled { return nil } model.FotoAttachmentID = fotoAttachmentID } if err := h.svc.Create(c.UserContext(), model); err != nil { return h.writeHelicopterServiceError(c, "HelicopterHandler.Create", err) } if len(model.FotoAttachmentID) == 16 { if err := syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, nil, model.FotoAttachmentID, actorUserID(c)); err != nil { return h.writeHelicopterServiceError(c, "HelicopterHandler.Create.Attachments", err) } } isInUse, _ := h.svc.IsInUse(c.UserContext(), model.ID) created, err := h.svc.GetByID(c.UserContext(), model.ID) if err == nil && created != nil { return response.Write(c, fiber.StatusCreated, h.helicopterResourceWithSchedules(c.UserContext(), created, isInUse)) } return response.Write(c, fiber.StatusCreated, h.helicopterResourceWithSchedules(c.UserContext(), model, isInUse)) } // UpdateHelicopter godoc // @Summary Update helicopter (partial) // @Description Patch helicopter by ID. // @Tags Facility - Helicopters // @Accept json // @Produce json // @Param id path string true "Helicopter ID (UUIDv7)" // @Param request body requestdto.HelicopterUpdateRequest true "JSON:API Helicopter update request" // @Success 200 {object} responsedto.HelicopterResponse // @Failure 422 {object} jsonapi.ErrorDocument // @Failure 404 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopters/update/{id} [patch] func (h *HelicopterHandler) Update(c *fiber.Ctx) error { svc, ok := h.svc.(helicopterDetailedWriter) if !ok { return writeHelicopterErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{ Status: "500", Title: "Update failed", Detail: "helicopter service is not configured", }}) } if h.updateHelicopterWithService(c, svc) { return nil } idStr := strings.TrimSpace(c.Params("id")) id, err := uuidv7.ParseString(idStr) if err != nil { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) } var req requestdto.HelicopterUpdateRequest if err := c.BodyParser(&req); err != nil { return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, errs) } if strings.TrimSpace(req.Data.ID) == "" { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is required", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } if req.Data.ID != idStr { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id does not match path id", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } attrs := req.Data.Attributes if attrs.Designation == nil && attrs.Identifier == nil && attrs.Type == nil && !attrs.SortKey.Set && attrs.Engine1 == nil && attrs.Engine2 == nil && attrs.Consumption == nil && attrs.NR1 == nil && attrs.NR2 == nil && attrs.LH == nil && attrs.RH == nil && attrs.MGB == nil && attrs.IGB == nil && attrs.TGB == nil && attrs.UTCOffset == nil && attrs.FileUUID == nil && attrs.Dry == nil && attrs.AOG == nil && attrs.AOGReason == nil && attrs.Note == nil && attrs.IsActive == nil { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "at least one attribute must be provided", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"}, }}) } existing, err := h.svc.GetByID(c.UserContext(), id) if err != nil || existing == nil { return writeHelicopterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "helicopter not found", }}) } prevFotoAttachmentID := append([]byte(nil), existing.FotoAttachmentID...) currentAOG := existing.AirOnGround if attrs.Designation != nil { v := strings.TrimSpace(*attrs.Designation) if v == "" { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "designation cannot be empty", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/designation"}, }}) } existing.Designation = v } if attrs.Identifier != nil { raw := normalizeHelicopterIdentifier(*attrs.Identifier) if raw == "" { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "identifier cannot be empty", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/identifier"}, }}) } exists, existsErr := h.svc.ExistsByIdentifier(c.UserContext(), raw, existing.ID) if existsErr != nil { return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Update failed", Detail: existsErr.Error(), }}) } if exists { return writeHelicopterErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{ Status: "409", Title: "Conflict", Detail: "Serial number already exists", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/identifier"}, }}) } existing.Identifier = raw } if attrs.Type != nil { v := strings.TrimSpace(*attrs.Type) if v == "" { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "type cannot be empty", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/type"}, }}) } existing.Type = v } sortKeyPatch := helicopter.SortKeyPatch{Set: attrs.SortKey.Set} if attrs.SortKey.Set && attrs.SortKey.Valid { v := attrs.SortKey.Value sortKeyPatch.Value = &v } h.svc.ApplySortKeyPatch(existing, sortKeyPatch) if attrs.Engine1 != nil { existing.Engine1 = strings.TrimSpace(*attrs.Engine1) } if attrs.Engine2 != nil { existing.Engine2 = strings.TrimSpace(*attrs.Engine2) } if attrs.Consumption != nil { existing.Consumption = *attrs.Consumption } if attrs.NR1 != nil { existing.NR1 = *attrs.NR1 } if attrs.NR2 != nil { existing.NR2 = *attrs.NR2 } if attrs.LH != nil { existing.LH = *attrs.LH } if attrs.RH != nil { existing.RH = *attrs.RH } if attrs.MGB != nil { existing.MGB = *attrs.MGB } if attrs.IGB != nil { existing.IGB = *attrs.IGB } if attrs.TGB != nil { existing.TGB = *attrs.TGB } if attrs.UTCOffset != nil { existing.UTCOffset = *attrs.UTCOffset } if attrs.FileUUID != nil { if hasBlankOptionalString(attrs.FileUUID) { existing.FotoAttachmentID = nil } else { fotoAttachmentID, handled := h.resolveExistingFotoAttachmentOrFileID(c, existing.ID, attrs.FileUUID, "Update failed") if handled { return nil } existing.FotoAttachmentID = fotoAttachmentID } } if attrs.Dry != nil { existing.Dry = *attrs.Dry } if attrs.AOG != nil { existing.AirOnGround = *attrs.AOG } if attrs.AOGReason != nil { existing.AOGReason = strings.TrimSpace(*attrs.AOGReason) } if errObj := validateAOGReason(existing.AirOnGround, existing.AOGReason); errObj != nil { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj}) } if attrs.AOG != nil && *attrs.AOG != currentAOG { if h.requireAOGPINVerification(c) { return nil } } if attrs.Note != nil { existing.Note = strings.TrimSpace(*attrs.Note) } if attrs.IsActive != nil { existing.IsActive = *attrs.IsActive } existing.UpdatedBy = actorUserID(c) if err := h.svc.Update(c.UserContext(), existing); err != nil { appErr := mapHelicopterServiceErrorWithFallback(err, apperrorsx.ErrHelicopterUpdateFailed) if appErr != nil && appErr.Code == apperrorsx.CodeHelicopterDuplicate { appErr.Title = "Conflict" appErr.Message = "Serial number already exists" appErr.Source = &jsonapi.ErrorSource{Pointer: "/data/attributes/identifier"} } return apperrorsx.Write(c, appErr) } if err := syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, prevFotoAttachmentID, existing.FotoAttachmentID, actorUserID(c)); err != nil { return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Update failed", Detail: safeInternalDetail(), }}) } isInUse, _ := h.svc.IsInUse(c.UserContext(), existing.ID) updated, err := h.svc.GetByID(c.UserContext(), existing.ID) if err == nil && updated != nil { return response.Write(c, fiber.StatusOK, h.helicopterResourceWithSchedules(c.UserContext(), updated, isInUse)) } return response.Write(c, fiber.StatusOK, h.helicopterResourceWithSchedules(c.UserContext(), existing, isInUse)) } // DeleteHelicopter godoc // @Summary Delete helicopter // @Tags Facility - Helicopters // @Produce json // @Param id path string true "Helicopter ID (UUIDv7)" // @Success 200 {object} jsonapi.Document // @Failure 404 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopters/delete/{id} [delete] func (h *HelicopterHandler) Delete(c *fiber.Ctx) error { idStr := strings.TrimSpace(c.Params("id")) id, err := uuidv7.ParseString(idStr) if err != nil { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) } existing, _ := h.svc.GetByID(c.UserContext(), id) if existing == nil { return writeHelicopterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "helicopter not found", }}) } var previousFotoAttachmentID []byte previousFotoAttachmentID = append([]byte(nil), existing.FotoAttachmentID...) if err := h.svc.Delete(c.UserContext(), id); err != nil { return apperrorsx.Write(c, mapHelicopterServiceErrorWithFallback(err, apperrorsx.ErrHelicopterDeleteFailed)) } if len(previousFotoAttachmentID) == 16 { if err := syncAttachmentLifecycleClear(c.UserContext(), h.fileManager, previousFotoAttachmentID, actorUserID(c)); err != nil { return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Delete failed", Detail: safeInternalDetail(), }}) } } return response.Write(c, fiber.StatusOK, jsonapi.Resource{ Type: "helicopter_delete", Attributes: map[string]any{ "deleted": true, }, }) } // GenerateHelicopterNextReportNumber godoc // @Summary Generate next helicopter report number // @Description Returns report number format -NNNN (example: SN0901-0001). The identifier is used as the report-code prefix and the numeric suffix is auto-incremented by backend per helicopter. // @Tags Facility - Helicopters // @Produce json // @Param id path string true "Helicopter ID (UUIDv7)" // @Success 200 {object} responsedto.HelicopterNextReportNumberResponse // @Failure 404 {object} jsonapi.ErrorDocument // @Failure 422 {object} jsonapi.ErrorDocument // @Failure 400 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopters/{id}/reports/next-number [post] func (h *HelicopterHandler) NextReportNumber(c *fiber.Ctx) error { idStr := strings.TrimSpace(c.Params("id")) id, err := uuidv7.ParseString(idStr) if err != nil { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) } reportNumber, sequence, err := h.svc.NextReportNumber(c.UserContext(), id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return writeHelicopterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "helicopter not found", }}) } return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Generate failed", Detail: safeInternalDetail(), }}) } existing, err := h.svc.GetByID(c.UserContext(), id) if err != nil || existing == nil { return writeHelicopterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "helicopter not found", }}) } return response.Write(c, fiber.StatusOK, responsedto.HelicopterNextReportNumberResponse{ Data: responsedto.HelicopterNextReportNumberResource{ Type: "helicopter_report_number", ID: idStr, Attributes: responsedto.HelicopterNextReportNumberAttributes{ Identifier: existing.Identifier, Sequence: sequence, ReportNumber: reportNumber, }, }, }) } // GetHelicopterByID godoc // @Summary Get helicopter by ID // @Tags Facility - Helicopters // @Produce json // @Param id path string true "Helicopter ID (UUIDv7)" // @Success 200 {object} responsedto.HelicopterResponse // @Failure 404 {object} jsonapi.ErrorDocument // @Router /api/v1/helicopters/get/{id} [get] func (h *HelicopterHandler) Get(c *fiber.Ctx) error { idStr := strings.TrimSpace(c.Params("id")) id, err := uuidv7.ParseString(idStr) if err != nil { return writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) } model, err := h.svc.GetByID(c.UserContext(), id) if err != nil || model == nil { return writeHelicopterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "helicopter not found", }}) } isInUse, _ := h.svc.IsInUse(c.UserContext(), model.ID) return response.Write(c, fiber.StatusOK, h.helicopterResourceWithSchedules(c.UserContext(), model, isInUse)) } // ListHelicopters godoc // @Summary List helicopters // @Description JSON:API list with pagination, filtering and sorting. Sort values: designation, -designation, sortkey, -sortkey. // @Tags Facility - Helicopters // @Produce json // @Param filter[search] query string false "Search by designation/identifier/type" // @Param filter[status] query string false "Filter by operational status; comma-separated for multiple (e.g. available,booked). Values: available, booked, aog, mcf" // @Param page[number] query int false "Page number (default 1)" // @Param page[size] query int false "Page size (default 20, max 100)" // @Param sort query string false "Sort order" // @Success 200 {object} responsedto.HelicopterListResponse // @Router /api/v1/helicopters/get-all [get] func (h *HelicopterHandler) List(c *fiber.Ctx) error { filter := c.Query("filter[search]") if filter == "" { filter = c.Query("filter[name]") } pageNumber := parseIntDefault(c.Query("page[number]"), 1) pageSize := parseIntDefault(c.Query("page[size]"), 20) if pageSize > 100 { pageSize = 100 } if pageNumber < 1 { pageNumber = 1 } offset := (pageNumber - 1) * pageSize _ = offset sort := c.Query("sort") statuses, err := parseHelicopterStatusFilter(c.Query("filter[status]")) if err != nil { return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid status filter", Detail: safeInternalDetail(), }}) } var groundedIDs [][]byte if len(statuses) > 0 { groundedIDs = groundedHelicopterIDs(c.UserContext(), h.complaintSvc, time.Now().UTC()) } rows, total, err := h.svc.List(c.UserContext(), filter, statuses, sort, 0, 0, groundedIDs) if err != nil { return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } ids := make([][]byte, 0, len(rows)) for i := range rows { ids = append(ids, rows[i].ID) } inUseByID, err := h.svc.InUseByAircraftIDs(c.UserContext(), ids) if err != nil { return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } fsByID := h.latestFleetStatusByHelicopter(c.UserContext(), ids) groundReasons := groundingReasonHelicopterSet(c.UserContext(), h.complaintSvc, ids, time.Now().UTC()) mcfPending := mcfPendingHelicopterSet(c.UserContext(), h.mcfSvc, ids) data := make([]responsedto.HelicopterResource, 0, len(rows)) for i := range rows { inUse := inUseByID[string(rows[i].ID)] res := h.helicopterResource(c.UserContext(), &rows[i], inUse) applyHelicopterFleetStatus(&res, &rows[i], inUse, groundReasons[string(rows[i].ID)], mcfPending[string(rows[i].ID)], fsByID[string(rows[i].ID)]) data = append(data, res) } meta := map[string]any{ "page_number": pageNumber, "page_size": pageSize, "total": total, } return response.WriteWithMeta(c, fiber.StatusOK, data, meta) } // ListHelicoptersDatatable godoc // @Summary List helicopters (datatable) // @Description Datatable response with simple pagination params. // @Tags Facility - Helicopters // @Produce json // @Param page query int false "Page number (default 1)" // @Param size query int false "Page size (default 20, max 100)" // @Param limit query int false "Page size override (same as size)" // @Param draw query int false "Draw counter (optional)" // @Param search query string false "Search term" // @Param status query string false "Filter by operational status; comma-separated for multiple (e.g. available,booked). Values: available, booked, aog, mcf" // @Success 200 {object} responsedto.HelicopterDataTableResponse // @Router /api/v1/helicopters/get-all/dt [get] func (h *HelicopterHandler) ListDatatable(c *fiber.Ctx) error { pageNumber := parseIntDefault(c.Query("page"), 1) if pageNumber < 1 { pageNumber = 1 } length := parseIntDefault(c.Query("limit"), 0) if length <= 0 { length = parseIntDefault(c.Query("size"), 20) } if length > 100 { length = 100 } if length < 1 { length = 20 } start := (pageNumber - 1) * length draw := parseIntDefault(c.Query("draw"), pageNumber) search := c.Query("search") statuses, err := parseHelicopterStatusFilter(c.Query("status")) if err != nil { return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid status filter", Detail: safeInternalDetail(), }}) } var groundedIDs [][]byte if len(statuses) > 0 { groundedIDs = groundedHelicopterIDs(c.UserContext(), h.complaintSvc, time.Now().UTC()) } rows, total, err := h.svc.List(c.UserContext(), search, statuses, "", length, start, groundedIDs) if err != nil { return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } ids := make([][]byte, 0, len(rows)) for i := range rows { ids = append(ids, rows[i].ID) } inUseByID, err := h.svc.InUseByAircraftIDs(c.UserContext(), ids) if err != nil { return writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: safeInternalDetail(), }}) } fsByID := h.latestFleetStatusByHelicopter(c.UserContext(), ids) groundReasons := groundingReasonHelicopterSet(c.UserContext(), h.complaintSvc, ids, time.Now().UTC()) mcfPending := mcfPendingHelicopterSet(c.UserContext(), h.mcfSvc, ids) data := make([]responsedto.HelicopterResource, 0, len(rows)) for i := range rows { inUse := inUseByID[string(rows[i].ID)] res := h.helicopterResource(c.UserContext(), &rows[i], inUse) applyHelicopterFleetStatus(&res, &rows[i], inUse, groundReasons[string(rows[i].ID)], mcfPending[string(rows[i].ID)], fsByID[string(rows[i].ID)]) data = append(data, res) } meta := map[string]any{ "draw": draw, "records_total": total, "records_filtered": total, } return response.WriteWithMeta(c, fiber.StatusOK, data, meta) } func (h *HelicopterHandler) helicopterResource(ctx context.Context, row *helicopter.Helicopter, isInUse bool) responsedto.HelicopterResource { return helicopterResourceWithStorage(ctx, h.storage, h.reserveAc, row, isInUse) } func helicopterResource(h *helicopter.Helicopter) responsedto.HelicopterResource { return helicopterResourceWithStorage(context.Background(), nil, nil, h, false) } func helicopterResourceWithStorage(ctx context.Context, storage fileManagerDownloadURLStorage, reserveAcSvc helicopterLastDailyInspectionProvider, row *helicopter.Helicopter, isInUse bool) responsedto.HelicopterResource { id, _ := uuidv7.BytesToString(row.ID) var foto *shareddto.HelicopterFoto fotoUUID := idString(row.FotoAttachmentID) fotoDownloadURL := "" fotoThumbnailURL := "" var fotoOriginalSizeBytes *int64 if row.FotoAttachment != nil && row.FotoAttachment.File != nil { asset := resolveFileManagerDownloadURLs(ctx, storage, row.FotoAttachment.File) fotoDownloadURL = strings.TrimSpace(asset.OriginalURL) fotoThumbnailURL = strings.TrimSpace(asset.ThumbnailURL) fotoOriginalSizeBytes = int64Ptr(asset.OriginalSizeBytes) } if fotoUUID != "" || fotoDownloadURL != "" || fotoThumbnailURL != "" || fotoOriginalSizeBytes != nil { foto = &shareddto.HelicopterFoto{ UUID: fotoUUID, DownloadURL: fotoDownloadURL, ThumbnailDownloadURL: fotoThumbnailURL, OriginalSizeBytes: fotoOriginalSizeBytes, } } return responsedto.HelicopterResource{ Type: "helicopter", ID: id, Attributes: responsedto.HelicopterAttributes{ Designation: row.Designation, Identifier: row.Identifier, Type: row.Type, SortKey: cloneIntPointer(row.SortKey), Engine1: row.Engine1, Engine2: row.Engine2, Consumption: row.Consumption, NR1: row.NR1, NR2: row.NR2, LH: row.LH, RH: row.RH, MGB: row.MGB, IGB: row.IGB, TGB: row.TGB, UTCOffset: row.UTCOffset, Foto: foto, Dry: row.Dry, AOG: row.AirOnGround, IsInUse: isInUse, Status: row.DeriveStatus(helicopter.StatusInput{IsBook: isInUse}), AOGReason: row.AOGReason, Note: row.Note, IsActive: row.IsActive, CreatedBy: helicopterAuditUser(row.CreatedBy, row.CreatedByName), CreatedAt: row.CreatedAt.UTC().Format(time.RFC3339), UpdatedBy: helicopterAuditUser(row.UpdatedBy, row.UpdatedByName), UpdatedAt: row.UpdatedAt.UTC().Format(time.RFC3339), LastDailyInspection: helicopterLastDailyInspectionResource(ctx, reserveAcSvc, row.ID), // Default to the full empty inspection set; handlers with a fleet // status service enrich this with the helicopter's actual schedules. MaintenanceSchedules: fleetStatusMaintenanceSchedules(nil), }, } } // parseHelicopterStatusFilter parses a comma-separated status filter (e.g. // "available,booked") into a validated, de-duplicated slice. Empty entries are // ignored; an empty input yields a nil slice (no filtering). Unrecognised // values produce an error so the caller can reject the request. func parseHelicopterStatusFilter(raw string) ([]string, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil } out := make([]string, 0) seen := make(map[string]struct{}) invalid := make([]string, 0) for _, part := range strings.Split(raw, ",") { s := strings.ToLower(strings.TrimSpace(part)) if s == "" { continue } if !helicopter.IsValidStatus(s) { invalid = append(invalid, s) continue } if _, ok := seen[s]; ok { continue } seen[s] = struct{}{} out = append(out, s) } if len(invalid) > 0 { return nil, fmt.Errorf("unknown status value(s): %s", strings.Join(invalid, ", ")) } return out, nil } func helicopterLastDailyInspectionResource(ctx context.Context, provider helicopterLastDailyInspectionProvider, helicopterID []byte) *shareddto.HelicopterLastDailyInspection { if provider == nil || len(helicopterID) != 16 { return nil } lastDaily, err := provider.GetLastDailyInspectionSummaryByHelicopterID(ctx, helicopterID) if err != nil || lastDaily == nil { return nil } return &shareddto.HelicopterLastDailyInspection{ InspectionID: lastDaily.InspectionID, Status: lastDaily.Status, Inspector: shareddto.HelicopterLastDailyInspector{ ID: lastDaily.InspectorID, Name: lastDaily.InspectorName, LicenseNo: lastDaily.InspectorLicenseNo, }, Base: shareddto.HelicopterLastDailyBase{ ID: lastDaily.BaseID, BaseAbbreviation: lastDaily.BaseAbbreviation, BaseName: lastDaily.BaseName, Type: lastDaily.BaseType, }, InspectedAt: lastDaily.InspectedAt.UTC().Format(time.RFC3339), UTCTime: lastDaily.InspectedAt.UTC().Format("15:04"), InspectionFilesChecklist: shareddto.HelicopterInspectionFilesChecklist{ Completed: lastDaily.InspectionFilesChecklistCompleted, Total: lastDaily.InspectionFilesChecklistTotal, }, } } func helicopterAuditUser(id []byte, name string) *shareddto.HelicopterAuditUser { return &shareddto.HelicopterAuditUser{ ID: idString(id), Name: strings.TrimSpace(name), } } func (h *HelicopterHandler) resolveFotoAttachmentID( c *fiber.Ctx, helicopterID []byte, rawFileUUID *string, errorTitle string, ) ([]byte, bool) { return h.resolveAttachmentIDFromInput(c, attachmentresolver.Params{ FileManager: h.fileManager, RawFileID: rawFileUUID, FileField: "file_uuid", RequireValue: false, RefType: helicopterPhotoAttachmentRefType, RefID: idString(helicopterID), Category: ptrString(helicopterPhotoAttachmentCategory), IsPrimary: true, ActorID: actorUserID(c), ValidateAttachmentExists: false, }, errorTitle) } func (h *HelicopterHandler) resolveExistingFotoAttachmentOrFileID( c *fiber.Ctx, helicopterID []byte, rawUUID *string, errorTitle string, ) ([]byte, bool) { if hasNonEmptyOptionalString(rawUUID) && h.fileManager != nil { candidateID, err := uuidv7.ParseString(strings.TrimSpace(*rawUUID)) if err == nil { attachment, getErr := h.fileManager.GetAttachmentByID(c.UserContext(), candidateID) if getErr != nil { _ = writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: errorTitle, Detail: getErr.Error(), }}) return nil, true } if attachment != nil { return candidateID, false } } } return h.resolveFotoAttachmentID(c, helicopterID, rawUUID, errorTitle) } func (h *HelicopterHandler) resolveAttachmentIDFromInput( c *fiber.Ctx, params attachmentresolver.Params, errorTitle string, ) ([]byte, bool) { attachmentID, err := attachmentresolver.Resolve(c.UserContext(), params) if err == nil { return attachmentID, false } var resolveErr *attachmentresolver.ResolveError if errors.As(err, &resolveErr) { switch resolveErr.Code { case attachmentresolver.ErrorInvalidAttachmentUUID: _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: fmt.Sprintf("%s is invalid UUID", fieldOrDefault(resolveErr.AttachmentField, "attachment_id")), Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.AttachmentField, "attachment_id")}, }}) return nil, true case attachmentresolver.ErrorInvalidFileUUID: _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: fmt.Sprintf("%s is invalid UUID", fieldOrDefault(resolveErr.FileField, "file_id")), Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.FileField, "file_id")}, }}) return nil, true case attachmentresolver.ErrorAttachmentNotFound: _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: fmt.Sprintf("%s not found", fieldOrDefault(resolveErr.AttachmentField, "attachment_id")), Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.AttachmentField, "attachment_id")}, }}) return nil, true case attachmentresolver.ErrorRequireValue: _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: fmt.Sprintf("%s or %s is required", fieldOrDefault(resolveErr.AttachmentField, "attachment_id"), fieldOrDefault(resolveErr.FileField, "file_id")), Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"}, }}) return nil, true case attachmentresolver.ErrorCannotResolveFromFile: _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: fmt.Sprintf("cannot resolve attachment from %s", fieldOrDefault(resolveErr.FileField, "file_id")), Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.FileField, "file_id")}, }}) return nil, true case attachmentresolver.ErrorFileNotReadyForAttachment: _ = writeHelicopterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: fmt.Sprintf("%s is not ready yet; wait for file.updated SSE event", fieldOrDefault(resolveErr.FileField, "file_id")), Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.FileField, "file_id")}, }}) return nil, true case attachmentresolver.ErrorFileManagerUnavailable: _ = writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: errorTitle, Detail: resolveErr.Error(), }}) return nil, true } } _ = writeHelicopterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: errorTitle, Detail: safeInternalDetail(), }}) return nil, true } func (h *HelicopterHandler) requireAOGPINVerification(c *fiber.Ctx) bool { if h.pinVerifier == nil { _ = writeHelicopterErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "auth service not configured", }}) return true } userID := actorUserID(c) if len(userID) == 0 { _ = writeHelicopterErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) return true } token := c.Get(pinVerificationHeader) if sessionVerifier, ok := h.pinVerifier.(helicopterPINSessionVerifier); ok { sessionID := "" for _, accessToken := range appservice.CookieValues(c.Get(fiber.HeaderCookie), sessionVerifier.AccessCookieName()) { sid, err := sessionVerifier.ParseAccessTokenSessionID(c.UserContext(), accessToken) if err == nil { sessionID = sid break } } if sessionID == "" { _ = writeHelicopterErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "invalid or expired token", }}) return true } if err := sessionVerifier.ConsumeSecurityPINActionTokenInSession(c.UserContext(), userID, sharedconst.PinActionHelicopterAOGChange, token, sessionID); err != nil { _ = writeHelicopterErrors(c, fiber.StatusAccepted, []jsonapi.ErrorObject{{ Status: "202", Title: "PIN verification required", Detail: "valid PIN action token is required", Source: &jsonapi.ErrorSource{Pointer: "/headers/X-PIN-Verification-Token"}, }}) return true } return false } if err := h.pinVerifier.ConsumeSecurityPINActionToken(c.UserContext(), userID, sharedconst.PinActionHelicopterAOGChange, token); err != nil { _ = writeHelicopterErrors(c, fiber.StatusAccepted, []jsonapi.ErrorObject{{ Status: "202", Title: "PIN verification required", Detail: "valid PIN action token is required", Source: &jsonapi.ErrorSource{Pointer: "/headers/X-PIN-Verification-Token"}, }}) return true } return false } func boolOrDefault(v *bool, def bool) bool { if v == nil { return def } return *v } func intOrDefault(v *int, def int) int { if v == nil { return def } return *v } func floatOrDefault(v *float64, def float64) float64 { if v == nil { return def } return *v } func stringOrDefault(v *string, def string) string { if v == nil { return def } return *v } func validateAOGReason(aog bool, reason string) *jsonapi.ErrorObject { if !aog { return nil } if strings.TrimSpace(reason) != "" { return nil } return &jsonapi.ErrorObject{ Status: "422", Title: "AOG reason required", Detail: "AOG reason must be provided when AOG is active (`aog=true`).", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/aog_reason"}, } } func int64Ptr(v int64) *int64 { return &v } func cloneIntPointer(v *int) *int { if v == nil { return nil } out := *v return &out } func normalizeHelicopterIdentifier(raw string) string { return strings.ToUpper(strings.TrimSpace(raw)) } func writeHelicopterErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error { enriched := make([]jsonapi.ErrorObject, 0, len(errs)) statusStr := strconv.Itoa(status) for i := range errs { e := errs[i] // Keep JSON:API error.status fully synchronized with HTTP status from handler. e.Status = statusStr if strings.TrimSpace(e.Code) == "" || strings.TrimSpace(e.ErrorCode) == "" { code, errorCode := inferHelicopterErrorCode(status, e.Title, e.Detail) if strings.TrimSpace(e.Code) == "" { e.Code = code } if strings.TrimSpace(e.ErrorCode) == "" { e.ErrorCode = errorCode } } enriched = append(enriched, e) } return response.WriteErrors(c, status, enriched) } func logHelicopterServiceError(c *fiber.Ctx, operation string, status int, code, errorCode string, err error) { requestID := requestIDFromCtx(c) args := []any{ slog.String("request_id", requestID), slog.String("operation", strings.TrimSpace(operation)), slog.Int("status", status), slog.String("code", strings.TrimSpace(code)), slog.String("error_code", strings.TrimSpace(errorCode)), slog.String("path", c.Path()), slog.Any("cause", err), } if status >= fiber.StatusInternalServerError { slog.ErrorContext(c.UserContext(), "helicopter service error", args...) return } slog.WarnContext(c.UserContext(), "helicopter service error", args...) } func mapHelicopterServiceErrorWithFallback(err error, fallback apperrorsx.Def) *apperrorsx.AppError { mapped := apperrorsx.Translate(apperrorsx.ModuleHelicopter, err) if mapped == nil { return apperrorsx.New(fallback).WithCause(err) } switch mapped.Code { case apperrorsx.CodeHelicopterDuplicate, apperrorsx.CodeHelicopterRelationDelete, apperrorsx.CodeHelicopterInvalidPayload, apperrorsx.CodeHelicopterNotFound: return mapped default: return apperrorsx.New(fallback).WithCause(err) } } func inferHelicopterErrorCode(status int, title, detail string) (string, string) { lowerTitle := strings.ToLower(strings.TrimSpace(title)) lowerDetail := strings.ToLower(strings.TrimSpace(detail)) switch status { case fiber.StatusAccepted: return apperrorsx.CodeHelicopterPinVerificationRequired, apperrorsx.ErrHelicopterPinVerificationRequired.ErrorCode case fiber.StatusUnauthorized: if strings.Contains(lowerDetail, "token") { return apperrorsx.CodeTokenInvalid, apperrorsx.ErrTokenInvalid.ErrorCode } return apperrorsx.CodeHelicopterUnauthorized, apperrorsx.ErrHelicopterUnauthorized.ErrorCode case fiber.StatusNotFound: return apperrorsx.CodeHelicopterNotFound, apperrorsx.ErrHelicopterNotFound.ErrorCode case fiber.StatusConflict: if strings.Contains(lowerDetail, "relation") || strings.Contains(lowerDetail, "referenced") { return apperrorsx.CodeHelicopterRelationDelete, apperrorsx.ErrHelicopterRelationDelete.ErrorCode } return apperrorsx.CodeHelicopterDuplicate, apperrorsx.ErrHelicopterDuplicate.ErrorCode case fiber.StatusUnprocessableEntity: if strings.Contains(lowerDetail, "type is oneof") || strings.Contains(lowerDetail, "type must be one of") { return apperrorsx.CodeHelicopterInvalidType, apperrorsx.ErrHelicopterInvalidType.ErrorCode } if strings.Contains(lowerDetail, "id is invalid uuid") { return apperrorsx.CodeHelicopterInvalidID, apperrorsx.ErrHelicopterInvalidID.ErrorCode } if strings.Contains(lowerDetail, "id is required") { return apperrorsx.CodeHelicopterIDRequired, apperrorsx.ErrHelicopterIDRequired.ErrorCode } if strings.Contains(lowerDetail, "id does not match path id") { return apperrorsx.CodeHelicopterIDMismatch, apperrorsx.ErrHelicopterIDMismatch.ErrorCode } if strings.Contains(lowerDetail, "at least one attribute must be provided") { return apperrorsx.CodeHelicopterAttributesRequired, apperrorsx.ErrHelicopterAttributesRequired.ErrorCode } return apperrorsx.CodeHelicopterInvalidPayload, apperrorsx.ErrHelicopterInvalidPayload.ErrorCode case fiber.StatusBadRequest: if strings.Contains(lowerTitle, "invalid json") { return apperrorsx.CodeHelicopterInvalidJSON, apperrorsx.ErrHelicopterInvalidJSON.ErrorCode } if strings.Contains(lowerTitle, "create failed") { return apperrorsx.CodeHelicopterCreateFailed, apperrorsx.ErrHelicopterCreateFailed.ErrorCode } if strings.Contains(lowerTitle, "update failed") { return apperrorsx.CodeHelicopterUpdateFailed, apperrorsx.ErrHelicopterUpdateFailed.ErrorCode } if strings.Contains(lowerTitle, "delete failed") { return apperrorsx.CodeHelicopterDeleteFailed, apperrorsx.ErrHelicopterDeleteFailed.ErrorCode } if strings.Contains(lowerTitle, "generate failed") { return apperrorsx.CodeHelicopterGenerateFailed, apperrorsx.ErrHelicopterGenerateFailed.ErrorCode } if strings.Contains(lowerTitle, "list failed") { return apperrorsx.CodeHelicopterListFailed, apperrorsx.ErrHelicopterListFailed.ErrorCode } return apperrorsx.CodeHelicopterCreateFailed, apperrorsx.ErrHelicopterCreateFailed.ErrorCode default: return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode } }