package service import ( "context" "errors" "net/http" "strings" "gorm.io/gorm" "wucher/internal/domain/helicopter" sharedconst "wucher/internal/shared/const" "wucher/internal/shared/pkg/uuidv7" ) 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 HelicopterWriteError struct { Status int Title string Detail string Pointer string } func (e *HelicopterWriteError) Error() string { if e == nil { return "" } return e.Detail } type HelicopterWriteResult struct { Row *helicopter.Helicopter IsInUse bool PreviousFotoAttachmentID []byte } type HelicopterCreateInput struct { ID []byte Designation string Identifier string Type string SortKey *int Engine1 string Engine2 string Consumption float64 NR1 bool NR2 bool LH bool RH bool MGB bool IGB bool TGB bool UTCOffset int FotoAttachmentID []byte Dry bool AirOnGround bool AOGReason string AOGSource string Note string IsActive bool ActorUserID []byte PINVerifier helicopterPINVerifier PINToken string AccessToken string } type HelicopterUpdateInput struct { ID []byte Designation *string Identifier *string Type *string SortKey helicopter.SortKeyPatch Engine1 *string Engine2 *string Consumption *float64 NR1 *bool NR2 *bool LH *bool RH *bool MGB *bool IGB *bool TGB *bool UTCOffset *int FotoAttachmentID []byte FotoAttachmentSet bool Dry *bool AirOnGround *bool AOGReason *string AOGSource *string Note *string IsActive *bool ActorUserID []byte PINVerifier helicopterPINVerifier PINToken string AccessToken string } func (s *HelicopterService) CreateDetailed(ctx context.Context, in HelicopterCreateInput) (*HelicopterWriteResult, error) { if s == nil || s.repo == nil { return nil, &HelicopterWriteError{Status: http.StatusInternalServerError, Title: "Create failed", Detail: "helicopter service is not configured"} } designation := strings.TrimSpace(in.Designation) identifier := normalizeHelicopterIdentifier(in.Identifier) heliType := strings.TrimSpace(in.Type) if designation == "" || identifier == "" || heliType == "" { return nil, &HelicopterWriteError{ Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "designation, identifier, and type are required", Pointer: "/data/attributes", } } exists, err := s.repo.ExistsByIdentifier(ctx, identifier, nil) if err != nil { return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Create failed", Detail: err.Error()} } if exists { return nil, &HelicopterWriteError{ Status: http.StatusConflict, Title: "Conflict", Detail: "Serial number already exists", Pointer: "/data/attributes/identifier", } } aogReason := strings.TrimSpace(in.AOGReason) if in.AirOnGround && aogReason == "" { return nil, &HelicopterWriteError{ Status: http.StatusUnprocessableEntity, Title: "AOG reason required", Detail: "AOG reason must be provided when AOG is active (`aog=true`).", Pointer: "/data/attributes/aog_reason", } } if in.AirOnGround { if err := verifyHelicopterAOGPIN(ctx, in.PINVerifier, in.AccessToken, in.ActorUserID, in.PINToken); err != nil { return nil, err } } row := &helicopter.Helicopter{ ID: append([]byte(nil), in.ID...), Designation: designation, Identifier: identifier, ReportSequence: 0, Type: heliType, SortKey: cloneIntPtr(in.SortKey), Engine1: strings.TrimSpace(in.Engine1), Engine2: strings.TrimSpace(in.Engine2), Consumption: in.Consumption, NR1: in.NR1, NR2: in.NR2, LH: in.LH, RH: in.RH, MGB: in.MGB, IGB: in.IGB, TGB: in.TGB, UTCOffset: in.UTCOffset, FotoAttachmentID: append([]byte(nil), in.FotoAttachmentID...), Dry: in.Dry, AirOnGround: in.AirOnGround, AOGReason: aogReason, AOGSource: func() string { if in.AirOnGround { return helicopter.AOGSourceManual } return helicopter.AOGSourceUnknown }(), Note: strings.TrimSpace(in.Note), IsActive: in.IsActive, CreatedBy: append([]byte(nil), in.ActorUserID...), UpdatedBy: append([]byte(nil), in.ActorUserID...), } if len(row.ID) == 0 { row.ID = uuidv7.MustBytes() } if err := s.Create(ctx, row); err != nil { return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Create failed", Detail: err.Error()} } created, err := s.GetByID(ctx, row.ID) if err != nil { return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Create failed", Detail: err.Error()} } if created == nil { created = row } isInUse, err := s.IsInUse(ctx, row.ID) if err != nil { return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Create failed", Detail: err.Error()} } return &HelicopterWriteResult{Row: created, IsInUse: isInUse}, nil } func (s *HelicopterService) UpdateDetailed(ctx context.Context, in HelicopterUpdateInput) (*HelicopterWriteResult, error) { if s == nil || s.repo == nil { return nil, &HelicopterWriteError{Status: http.StatusInternalServerError, Title: "Update failed", Detail: "helicopter service is not configured"} } if len(in.ID) != 16 { return nil, &HelicopterWriteError{ Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "id is invalid UUID", Pointer: "/path/id", } } existing, err := s.GetByID(ctx, in.ID) if err != nil { return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: err.Error()} } if existing == nil { return nil, &HelicopterWriteError{Status: http.StatusNotFound, Title: "Not found", Detail: "helicopter not found"} } prevAOG := existing.AirOnGround prevFotoAttachmentID := append([]byte(nil), existing.FotoAttachmentID...) if in.Designation != nil { v := strings.TrimSpace(*in.Designation) if v == "" { return nil, &HelicopterWriteError{ Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "designation cannot be empty", Pointer: "/data/attributes/designation", } } existing.Designation = v } if in.Identifier != nil { raw := normalizeHelicopterIdentifier(*in.Identifier) if raw == "" { return nil, &HelicopterWriteError{ Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "identifier cannot be empty", Pointer: "/data/attributes/identifier", } } exists, existsErr := s.repo.ExistsByIdentifier(ctx, raw, existing.ID) if existsErr != nil { return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: existsErr.Error()} } if exists { return nil, &HelicopterWriteError{ Status: http.StatusConflict, Title: "Conflict", Detail: "Serial number already exists", Pointer: "/data/attributes/identifier", } } existing.Identifier = raw } if in.Type != nil { v := strings.TrimSpace(*in.Type) if v == "" { return nil, &HelicopterWriteError{ Status: http.StatusUnprocessableEntity, Title: "Validation error", Detail: "type cannot be empty", Pointer: "/data/attributes/type", } } existing.Type = v } s.ApplySortKeyPatch(existing, in.SortKey) if in.Engine1 != nil { existing.Engine1 = strings.TrimSpace(*in.Engine1) } if in.Engine2 != nil { existing.Engine2 = strings.TrimSpace(*in.Engine2) } if in.Consumption != nil { existing.Consumption = *in.Consumption } if in.NR1 != nil { existing.NR1 = *in.NR1 } if in.NR2 != nil { existing.NR2 = *in.NR2 } if in.LH != nil { existing.LH = *in.LH } if in.RH != nil { existing.RH = *in.RH } if in.MGB != nil { existing.MGB = *in.MGB } if in.IGB != nil { existing.IGB = *in.IGB } if in.TGB != nil { existing.TGB = *in.TGB } if in.UTCOffset != nil { existing.UTCOffset = *in.UTCOffset } if in.FotoAttachmentSet { if len(in.FotoAttachmentID) == 0 { existing.FotoAttachmentID = nil } else { existing.FotoAttachmentID = append([]byte(nil), in.FotoAttachmentID...) } } if in.Dry != nil { existing.Dry = *in.Dry } if in.AirOnGround != nil { existing.AirOnGround = *in.AirOnGround if *in.AirOnGround { existing.AOGSource = helicopter.AOGSourceManual } } if in.AOGReason != nil { existing.AOGReason = strings.TrimSpace(*in.AOGReason) } if in.AOGSource != nil { existing.AOGSource = strings.TrimSpace(*in.AOGSource) } if existing.AirOnGround && strings.TrimSpace(existing.AOGReason) == "" { return nil, &HelicopterWriteError{ Status: http.StatusUnprocessableEntity, Title: "AOG reason required", Detail: "AOG reason must be provided when AOG is active (`aog=true`).", Pointer: "/data/attributes/aog_reason", } } if in.AirOnGround != nil && *in.AirOnGround != prevAOG { if err := verifyHelicopterAOGPIN(ctx, in.PINVerifier, in.AccessToken, in.ActorUserID, in.PINToken); err != nil { return nil, err } } if in.Note != nil { existing.Note = strings.TrimSpace(*in.Note) } if in.IsActive != nil { existing.IsActive = *in.IsActive } existing.UpdatedBy = append([]byte(nil), in.ActorUserID...) if err := s.Update(ctx, existing); err != nil { return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: err.Error()} } updated, err := s.GetByID(ctx, existing.ID) if err != nil { return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: err.Error()} } if updated == nil { updated = existing } isInUse, err := s.IsInUse(ctx, existing.ID) if err != nil { return nil, &HelicopterWriteError{Status: http.StatusBadRequest, Title: "Update failed", Detail: err.Error()} } return &HelicopterWriteResult{Row: updated, IsInUse: isInUse, PreviousFotoAttachmentID: prevFotoAttachmentID}, nil } func verifyHelicopterAOGPIN( ctx context.Context, verifier helicopterPINVerifier, accessToken string, userID []byte, token string, ) error { if verifier == nil { return &HelicopterWriteError{ Status: http.StatusUnauthorized, Title: "Unauthorized", Detail: "auth service not configured", } } if len(userID) == 0 { return &HelicopterWriteError{ Status: http.StatusUnauthorized, Title: "Unauthorized", Detail: "missing auth context", } } if sessionVerifier, ok := verifier.(helicopterPINSessionVerifier); ok { sessionID := "" if accessToken = strings.TrimSpace(accessToken); accessToken != "" { sid, err := sessionVerifier.ParseAccessTokenSessionID(ctx, accessToken) if err != nil { return &HelicopterWriteError{ Status: http.StatusUnauthorized, Title: "Unauthorized", Detail: "invalid or expired token", } } sessionID = sid } if err := sessionVerifier.ConsumeSecurityPINActionTokenInSession(ctx, userID, sharedconst.PinActionHelicopterAOGChange, token, sessionID); err != nil { return &HelicopterWriteError{ Status: http.StatusAccepted, Title: "PIN verification required", Detail: "valid PIN action token is required", Pointer: "/headers/X-PIN-Verification-Token", } } return nil } if err := verifier.ConsumeSecurityPINActionToken(ctx, userID, sharedconst.PinActionHelicopterAOGChange, token); err != nil { return &HelicopterWriteError{ Status: http.StatusAccepted, Title: "PIN verification required", Detail: "valid PIN action token is required", Pointer: "/headers/X-PIN-Verification-Token", } } return nil } func normalizeHelicopterIdentifier(raw string) string { return strings.ToUpper(strings.TrimSpace(raw)) } func cloneIntPtr(v *int) *int { if v == nil { return nil } out := *v return &out } func isHelicopterNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) }