444 lines
12 KiB
Go
444 lines
12 KiB
Go
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)
|
|
}
|