2029 lines
76 KiB
Go
2029 lines
76 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"gorm.io/gorm"
|
|
|
|
authdomain "wucher/internal/domain/auth"
|
|
basedomain "wucher/internal/domain/base"
|
|
beforeflightinspection "wucher/internal/domain/before_flight_inspection"
|
|
dutyroster "wucher/internal/domain/duty_roster"
|
|
filemanager "wucher/internal/domain/file_manager"
|
|
fleetstatus "wucher/internal/domain/fleet_status"
|
|
"wucher/internal/domain/flight"
|
|
flightinspection "wucher/internal/domain/flight_inspection"
|
|
flightinspectionfilechecklist "wucher/internal/domain/flight_inspection_file_checklist"
|
|
flightprepcheck "wucher/internal/domain/flight_prep_check"
|
|
"wucher/internal/domain/helicopter"
|
|
helicopterfile "wucher/internal/domain/helicopter_file"
|
|
reserveac "wucher/internal/domain/reserve_ac"
|
|
takeover "wucher/internal/domain/takeover"
|
|
"wucher/internal/repository/mysql"
|
|
"wucher/internal/service"
|
|
"wucher/internal/shared/pkg/appctx"
|
|
"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"
|
|
)
|
|
|
|
// takeoverHelicopterChecker exposes the helicopter availability check the
|
|
// takeover handler needs to reject already-booked helicopters.
|
|
type takeoverHelicopterChecker interface {
|
|
IsInUse(ctx context.Context, id []byte) (bool, error)
|
|
GetByID(ctx context.Context, id []byte) (*helicopter.Helicopter, error)
|
|
}
|
|
|
|
type TakeoverHandler struct {
|
|
flight flight.FlightService
|
|
base basedomain.Service
|
|
reserveAc reserveac.ReserveAcService
|
|
dutyRoster dutyroster.Service
|
|
db *gorm.DB
|
|
takeoverAc *service.TakeoverModuleService
|
|
inspect flightinspection.FlightInspectionService
|
|
before beforeflightinspection.Service
|
|
prep flightprepcheck.Service
|
|
heliFile helicopterfile.Service
|
|
fileCk flightinspectionfilechecklist.Service
|
|
fileManager filemanager.Service
|
|
helicopter takeoverHelicopterChecker
|
|
fleetStatus helicopterFleetStatusProvider
|
|
complaintSvc complaintGroundingLister
|
|
mcfSvc mcfPendingChecker
|
|
storage fileManagerDownloadURLStorage
|
|
auth takeoverAuthService
|
|
validate *validators.Validator
|
|
}
|
|
|
|
type takeoverAuthService interface {
|
|
GetUserByID(ctx context.Context, id []byte) (*authdomain.User, error)
|
|
}
|
|
|
|
func NewTakeoverHandler(db *gorm.DB) *TakeoverHandler {
|
|
return &TakeoverHandler{
|
|
db: db,
|
|
validate: validators.New(),
|
|
}
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithDB(db *gorm.DB) *TakeoverHandler {
|
|
h.db = db
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) takeoverService() *service.TakeoverService {
|
|
if h != nil && h.db != nil {
|
|
return service.NewTakeoverService(h.db).
|
|
WithChecklistTemplateRepository(mysql.NewTakeoverRepository(h.db))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithFlightService(flightSvc flight.FlightService) *TakeoverHandler {
|
|
h.flight = flightSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithAuthService(authSvc takeoverAuthService) *TakeoverHandler {
|
|
h.auth = authSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithBaseService(baseSvc basedomain.Service) *TakeoverHandler {
|
|
h.base = baseSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithReserveAcService(reserveAcSvc reserveac.ReserveAcService) *TakeoverHandler {
|
|
h.reserveAc = reserveAcSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithDutyRosterService(dutyRosterSvc dutyroster.Service) *TakeoverHandler {
|
|
h.dutyRoster = dutyRosterSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithFleetStatusService(fleetStatusSvc helicopterFleetStatusProvider) *TakeoverHandler {
|
|
h.fleetStatus = fleetStatusSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithHelicopterService(helicopterSvc takeoverHelicopterChecker) *TakeoverHandler {
|
|
h.helicopter = helicopterSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithComplaintService(complaintSvc complaintGroundingLister) *TakeoverHandler {
|
|
h.complaintSvc = complaintSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithMCFService(mcfSvc mcfPendingChecker) *TakeoverHandler {
|
|
h.mcfSvc = mcfSvc
|
|
return h
|
|
}
|
|
|
|
// existingTakeoverHelicopterID resolves the helicopter currently assigned to the
|
|
// takeover behind the given flight (via its reserve AC), used to skip the
|
|
// availability check when the helicopter is not being changed.
|
|
func (h *TakeoverHandler) existingTakeoverHelicopterID(ctx context.Context, fl *flight.Flight) []byte {
|
|
if fl == nil || h.reserveAc == nil || len(fl.ReserveAcID) != 16 {
|
|
return nil
|
|
}
|
|
rac, err := h.reserveAc.GetByID(ctx, fl.ReserveAcID)
|
|
if err != nil || rac == nil {
|
|
return nil
|
|
}
|
|
return rac.AircraftID
|
|
}
|
|
|
|
// helicopterBookedError returns a conflict error when the requested helicopter is
|
|
// already booked (in use by an active flight/takeover). When current is the same
|
|
// helicopter (no change on update) the check is skipped.
|
|
func (h *TakeoverHandler) helicopterBookedError(ctx context.Context, requested, current []byte) *apperrorsx.AppError {
|
|
if h.helicopter == nil || len(requested) != 16 {
|
|
return nil
|
|
}
|
|
if len(current) == 16 && bytes.Equal(requested, current) {
|
|
return nil
|
|
}
|
|
inUse, err := h.helicopter.IsInUse(ctx, requested)
|
|
if err != nil || !inUse {
|
|
return nil
|
|
}
|
|
return apperrorsx.New(apperrorsx.ErrTakeoverHelicopterInUse)
|
|
}
|
|
|
|
func (h *TakeoverHandler) helicopterGroundedError(ctx context.Context, requested, current []byte) *apperrorsx.AppError {
|
|
if len(requested) != 16 {
|
|
return nil
|
|
}
|
|
if len(current) == 16 && bytes.Equal(requested, current) {
|
|
return nil
|
|
}
|
|
if h.mcfSvc != nil && mcfPendingHelicopterSet(ctx, h.mcfSvc, [][]byte{requested})[string(requested)] {
|
|
return apperrorsx.New(apperrorsx.ErrTakeoverHelicopterMCF)
|
|
}
|
|
grounded := false
|
|
if h.helicopter != nil {
|
|
if heli, err := h.helicopter.GetByID(ctx, requested); err == nil && heli != nil && heli.AirOnGround {
|
|
grounded = true
|
|
}
|
|
}
|
|
if !grounded && h.complaintSvc != nil {
|
|
grounded = groundingHelicopterSet(ctx, h.complaintSvc, [][]byte{requested}, time.Now().UTC())[string(requested)]
|
|
}
|
|
if grounded {
|
|
return apperrorsx.New(apperrorsx.ErrTakeoverHelicopterAOG)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *TakeoverHandler) takeoverUserHasGlobalAccess(ctx context.Context) bool {
|
|
return h.takeoverUserHasGlobalAccessForUserID(ctx, appctx.GetUserID(ctx))
|
|
}
|
|
|
|
func (h *TakeoverHandler) takeoverUserHasGlobalAccessForUserID(ctx context.Context, userID []byte) bool {
|
|
if h == nil || h.auth == nil {
|
|
return false
|
|
}
|
|
if len(userID) != 16 {
|
|
return false
|
|
}
|
|
user, err := h.auth.GetUserByID(ctx, userID)
|
|
if err != nil || user == nil {
|
|
return false
|
|
}
|
|
return takeoverUserHasRole(user, "admin")
|
|
}
|
|
|
|
func takeoverUserHasRole(user *authdomain.User, roleName string) bool {
|
|
if user == nil {
|
|
return false
|
|
}
|
|
for i := range user.Roles {
|
|
if strings.EqualFold(strings.TrimSpace(user.Roles[i].Name), roleName) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h *TakeoverHandler) hasAfterFlightInspection(ctx context.Context, flightInspectionID []byte) bool {
|
|
if h == nil || h.db == nil || len(flightInspectionID) != 16 {
|
|
return false
|
|
}
|
|
var count int64
|
|
if err := h.db.WithContext(ctx).
|
|
Table("after_flight_inspections").
|
|
Where("flight_inspection_id = ?", flightInspectionID).
|
|
Count(&count).Error; err != nil {
|
|
return false
|
|
}
|
|
return count > 0
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithFlightInspectionService(inspectionSvc flightinspection.FlightInspectionService) *TakeoverHandler {
|
|
h.inspect = inspectionSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithBeforeFlightInspectionService(beforeSvc beforeflightinspection.Service) *TakeoverHandler {
|
|
h.before = beforeSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithFlightPrepCheckService(prepSvc flightprepcheck.Service) *TakeoverHandler {
|
|
h.prep = prepSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithHelicopterFileService(heliFileSvc helicopterfile.Service) *TakeoverHandler {
|
|
h.heliFile = heliFileSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithFileManagerService(fileManagerSvc filemanager.Service) *TakeoverHandler {
|
|
h.fileManager = fileManagerSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithFlightInspectionFileChecklistService(fileChecklistSvc flightinspectionfilechecklist.Service) *TakeoverHandler {
|
|
h.fileCk = fileChecklistSvc
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithFileStorage(storage fileManagerDownloadURLStorage) *TakeoverHandler {
|
|
h.storage = storage
|
|
return h
|
|
}
|
|
|
|
func (h *TakeoverHandler) WithTakeoverAcService(takeoverAcSvc *service.TakeoverModuleService) *TakeoverHandler {
|
|
h.takeoverAc = takeoverAcSvc
|
|
return h
|
|
}
|
|
|
|
func logTakeoverError(c *fiber.Ctx, action string, err error) {
|
|
if err == nil {
|
|
return
|
|
}
|
|
if c != nil {
|
|
c.Locals(apperrorsx.ContextKeyHTTPErrorInternalDetail, buildTakeoverErrorDetail(action, err))
|
|
}
|
|
attrs := []any{
|
|
"action", action,
|
|
"method", c.Method(),
|
|
"path", c.Path(),
|
|
"request_id", c.Get("X-Request-Id"),
|
|
"error", err.Error(),
|
|
"error_type", fmt.Sprintf("%T", err),
|
|
"error_chain", buildErrorChain(err),
|
|
}
|
|
var appErr *apperrorsx.AppError
|
|
if errors.As(err, &appErr) && appErr != nil {
|
|
attrs = append(attrs,
|
|
"app_error_status", appErr.Status,
|
|
"app_error_code", appErr.Code,
|
|
"app_error_error_code", appErr.ErrorCode,
|
|
"app_error_title", appErr.Title,
|
|
"app_error_message", appErr.Message,
|
|
"app_error_detail", appErr.Detail,
|
|
)
|
|
if appErr.Cause != nil {
|
|
attrs = append(attrs, "app_error_cause", appErr.Cause.Error())
|
|
}
|
|
}
|
|
slog.Error("takeover handler error", attrs...)
|
|
}
|
|
|
|
func buildTakeoverErrorDetail(action string, err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, 8)
|
|
if strings.TrimSpace(action) != "" {
|
|
parts = append(parts, "action="+action)
|
|
}
|
|
parts = append(parts, "error="+err.Error())
|
|
if chain := buildErrorChain(err); len(chain) > 0 {
|
|
parts = append(parts, "chain="+strings.Join(chain, " <- "))
|
|
}
|
|
var appErr *apperrorsx.AppError
|
|
if errors.As(err, &appErr) && appErr != nil {
|
|
parts = append(parts,
|
|
fmt.Sprintf("app_error_status=%d", appErr.Status),
|
|
fmt.Sprintf("app_error_code=%s", appErr.Code),
|
|
fmt.Sprintf("app_error_error_code=%s", appErr.ErrorCode),
|
|
fmt.Sprintf("app_error_title=%s", appErr.Title),
|
|
fmt.Sprintf("app_error_message=%s", appErr.Message),
|
|
)
|
|
if appErr.Cause != nil {
|
|
parts = append(parts, "app_error_cause="+appErr.Cause.Error())
|
|
}
|
|
}
|
|
return strings.Join(parts, " | ")
|
|
}
|
|
|
|
func buildErrorChain(err error) []string {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
chain := make([]string, 0, 8)
|
|
seen := map[string]struct{}{}
|
|
for current := err; current != nil; current = errors.Unwrap(current) {
|
|
msg := current.Error()
|
|
if msg == "" {
|
|
msg = fmt.Sprintf("%T", current)
|
|
}
|
|
key := fmt.Sprintf("%T:%s", current, msg)
|
|
if _, ok := seen[key]; ok {
|
|
break
|
|
}
|
|
seen[key] = struct{}{}
|
|
chain = append(chain, fmt.Sprintf("%T: %s", current, msg))
|
|
}
|
|
return chain
|
|
}
|
|
|
|
func writeTakeoverErrors(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]
|
|
e.Status = statusStr
|
|
def := inferTakeoverDef(status, e.Title, e.Detail)
|
|
if strings.TrimSpace(e.Code) == "" {
|
|
e.Code = def.Code
|
|
}
|
|
if strings.TrimSpace(e.ErrorCode) == "" {
|
|
e.ErrorCode = def.ErrorCode
|
|
}
|
|
if strings.TrimSpace(e.Title) == "" {
|
|
e.Title = def.Title
|
|
}
|
|
e.Detail = def.Message
|
|
enriched = append(enriched, e)
|
|
}
|
|
return response.WriteErrors(c, status, enriched)
|
|
}
|
|
|
|
func inferTakeoverDef(status int, title, detail string) apperrorsx.Def {
|
|
lowerTitle := strings.ToLower(strings.TrimSpace(title))
|
|
lowerDetail := strings.ToLower(strings.TrimSpace(detail))
|
|
switch status {
|
|
case fiber.StatusNotFound:
|
|
switch {
|
|
case strings.Contains(lowerDetail, "flight"):
|
|
return apperrorsx.ErrTakeoverNotFound
|
|
default:
|
|
return apperrorsx.ErrTakeoverNotFound
|
|
}
|
|
case fiber.StatusUnprocessableEntity:
|
|
switch {
|
|
case strings.Contains(lowerDetail, "helicopter_file_id does not belong"):
|
|
return apperrorsx.ErrTakeoverFileSectionInvalid
|
|
case strings.Contains(lowerDetail, "invalid uuid"):
|
|
return apperrorsx.ErrTakeoverInvalidUUID
|
|
case strings.Contains(lowerDetail, "id is required"):
|
|
return apperrorsx.ErrTakeoverIDRequired
|
|
case strings.Contains(lowerDetail, "id does not match"):
|
|
return apperrorsx.ErrTakeoverIDMismatch
|
|
case strings.Contains(lowerTitle, "validation error"):
|
|
return apperrorsx.ErrTakeoverInvalidPayload
|
|
default:
|
|
return apperrorsx.ErrTakeoverInvalidPayload
|
|
}
|
|
case fiber.StatusBadRequest:
|
|
switch {
|
|
case strings.Contains(lowerTitle, "invalid json"):
|
|
return apperrorsx.ErrTakeoverInvalidJSON
|
|
case strings.Contains(lowerTitle, "create failed"):
|
|
return apperrorsx.ErrTakeoverCreateFailed
|
|
case strings.Contains(lowerTitle, "update failed"):
|
|
return apperrorsx.ErrTakeoverUpdateFailed
|
|
case strings.Contains(lowerTitle, "delete failed"):
|
|
return apperrorsx.ErrTakeoverDeleteFailed
|
|
case strings.Contains(lowerTitle, "get failed"):
|
|
return apperrorsx.ErrTakeoverGetFailed
|
|
case strings.Contains(lowerTitle, "list failed"):
|
|
return apperrorsx.ErrTakeoverListFailed
|
|
default:
|
|
return apperrorsx.ErrTakeoverCreateFailed
|
|
}
|
|
case fiber.StatusConflict:
|
|
return apperrorsx.ErrTakeoverHelicopterInUse
|
|
default:
|
|
return apperrorsx.ErrInternal
|
|
}
|
|
}
|
|
|
|
func takeoverAppErrorFromServiceErr(err error) *apperrorsx.AppError {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
detail := strings.ToLower(strings.TrimSpace(err.Error()))
|
|
switch {
|
|
case strings.Contains(detail, "helicopter_file_id does not belong to helicopter and section"),
|
|
strings.Contains(detail, "helicopter_file_id does not belong to selected helicopter/section"):
|
|
return apperrorsx.New(apperrorsx.ErrTakeoverFileSectionInvalid).WithCause(err)
|
|
case strings.Contains(detail, "must include all helicopter files"),
|
|
strings.Contains(detail, "must be fully checked"),
|
|
strings.Contains(detail, "edited template file must be included in the flight preparation file checklist"),
|
|
strings.Contains(detail, "is required"),
|
|
strings.Contains(detail, "invalid helicopter_file_id"),
|
|
strings.Contains(detail, "must be a valid uuid"),
|
|
strings.Contains(detail, "invalid roster_detail"):
|
|
return apperrorsx.New(apperrorsx.ErrTakeoverInvalidPayload).WithCause(err)
|
|
case strings.Contains(detail, "helicopter is already booked"):
|
|
return apperrorsx.New(apperrorsx.ErrTakeoverHelicopterInUse).WithCause(err)
|
|
case strings.Contains(detail, "not found"):
|
|
return apperrorsx.New(apperrorsx.ErrTakeoverNotFound).WithCause(err)
|
|
case strings.Contains(detail, "create failed"):
|
|
return apperrorsx.New(apperrorsx.ErrTakeoverCreateFailed).WithCause(err)
|
|
case strings.Contains(detail, "update failed"):
|
|
return apperrorsx.New(apperrorsx.ErrTakeoverUpdateFailed).WithCause(err)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// CreateTakeover godoc
|
|
// @Summary Create takeover in one request
|
|
// @Description Atomic create for duty roster + crews + reserve AC + pre-flight inspection + flight. The takeover inspection payload also includes fleet status files under `inspection.fleet_status_file`.
|
|
// @Description Optional takeover file uploads can be included via `data.attributes.files[*]` using upload intent UUIDs.
|
|
// @Description Edited template draft files created earlier can be linked by passing `data.attributes.edited_template_file_ids[*]`.
|
|
// @Tags Flight - Takeovers
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body requestdto.TakeoverCreateRequest true "Create takeover request"
|
|
// @Success 201 {object} responsedto.TakeoverResponse
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Failure 422 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/takeovers/create [post]
|
|
func (h *TakeoverHandler) CreateTakeover(c *fiber.Ctx) error {
|
|
takeoverSvc := h.takeoverService()
|
|
if h == nil || takeoverSvc == nil {
|
|
logTakeoverError(c, "CreateTakeover", errors.New("takeover service is not configured"))
|
|
return writeTakeoverErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{Status: "500", Title: "Create failed", Detail: "takeover service is not configured"}})
|
|
}
|
|
var req requestdto.TakeoverCreateRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
logTakeoverError(c, "CreateTakeover", err)
|
|
return writeTakeoverErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail()}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
var errObj *jsonapi.ErrorObject
|
|
var baseID []byte
|
|
hasBaseID := false
|
|
if req.Data.Attributes.BaseID != nil && strings.TrimSpace(*req.Data.Attributes.BaseID) != "" {
|
|
baseID, errObj = parseTakeoverUUID(*req.Data.Attributes.BaseID, "/data/attributes/base_id")
|
|
if errObj != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
|
}
|
|
hasBaseID = true
|
|
}
|
|
dutyDate, errObj := parseTakeoverDate(req.Data.Attributes.DutyDate)
|
|
if errObj != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
|
}
|
|
var helicopterID []byte
|
|
if req.Data.Attributes.AircraftID != nil && strings.TrimSpace(*req.Data.Attributes.AircraftID) != "" {
|
|
helicopterID, errObj = parseTakeoverUUID(*req.Data.Attributes.AircraftID, "/data/attributes/helicopter_id")
|
|
if errObj != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
|
}
|
|
}
|
|
if appErr := h.helicopterGroundedError(c.UserContext(), helicopterID, nil); appErr != nil {
|
|
return apperrorsx.Write(c, appErr)
|
|
}
|
|
var inspection *requestdto.ReserveAcInspectionCreateAttributes
|
|
if req.Data.Attributes.Inspection != nil {
|
|
inspection = req.Data.Attributes.Inspection
|
|
}
|
|
out, err := takeoverSvc.Create(c.UserContext(), service.TakeoverCreateInput{
|
|
BaseID: baseID,
|
|
HasBaseID: hasBaseID,
|
|
DutyDate: dutyDate,
|
|
HasDutyDate: true,
|
|
Notes: req.Data.Attributes.Notes,
|
|
RosterDetail: takeoverRosterDetailInput(req.Data.Attributes.Detail),
|
|
HasRosterDetail: req.Data.Attributes.Detail != nil,
|
|
HelicopterID: helicopterID,
|
|
HasHelicopterID: helicopterID != nil,
|
|
Inspection: inspection,
|
|
HasInspection: inspection != nil,
|
|
ActorUserID: actorUserID(c),
|
|
})
|
|
if err != nil {
|
|
logTakeoverError(c, "CreateTakeover", err)
|
|
if appErr := takeoverAppErrorFromServiceErr(err); appErr != nil {
|
|
return apperrorsx.Write(c, appErr)
|
|
}
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverCreateFailed).WithCause(err))
|
|
}
|
|
if err := h.attachTakeoverCreateFiles(c, takeoverSvc, out.TakeoverAcID, req.Data.Attributes.Files); err != nil {
|
|
return err
|
|
}
|
|
if err := h.attachTakeoverEditedTemplateFiles(c, takeoverSvc, out.TakeoverAcID, req.Data.Attributes.EditedTemplateFileIDs); err != nil {
|
|
return err
|
|
}
|
|
outResp, respErr := h.buildTakeoverResponse(c, out.TakeoverAcID)
|
|
if respErr != nil {
|
|
logTakeoverError(c, "CreateTakeover.buildResponse", respErr)
|
|
return writeTakeoverErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Create failed", Detail: respErr.Error()}})
|
|
}
|
|
if outResp == nil {
|
|
return writeTakeoverErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{Status: "500", Title: "Create failed", Detail: "takeover response not available"}})
|
|
}
|
|
return response.Write(c, fiber.StatusCreated, outResp.Data)
|
|
}
|
|
|
|
func takeoverRosterDetailInput(in *requestdto.TakeoverRosterDetailsInput) *requestdto.DutyRosterDetailsInput {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := requestdto.DutyRosterDetailsInput{
|
|
Pilot: make([]requestdto.DutyRosterPilotCrewInput, 0, len(in.Pilot)),
|
|
Doctor: make([]requestdto.DutyRosterCrewInput, 0, len(in.Doctor)),
|
|
Rescuer: make([]requestdto.DutyRosterCrewInput, 0, len(in.Rescuer)),
|
|
Helper: make([]requestdto.DutyRosterCrewInput, 0, len(in.Helper)),
|
|
OtherPerson: make([]requestdto.DutyRosterOtherPersonInput, 0, len(in.OtherPerson)),
|
|
}
|
|
for _, item := range in.Pilot {
|
|
out.Pilot = append(out.Pilot, requestdto.DutyRosterPilotCrewInput{
|
|
ID: item.ID,
|
|
FlightInstructor: item.FlightInstructor,
|
|
LineChecker: item.LineChecker,
|
|
Supervisor: item.Supervisor,
|
|
Examiner: item.Examiner,
|
|
CoPilot: item.CoPilot,
|
|
CrewType: item.CrewType,
|
|
})
|
|
}
|
|
for _, item := range in.Doctor {
|
|
out.Doctor = append(out.Doctor, requestdto.DutyRosterCrewInput{ID: item.ID})
|
|
}
|
|
for _, item := range in.Rescuer {
|
|
out.Rescuer = append(out.Rescuer, requestdto.DutyRosterCrewInput{ID: item.ID})
|
|
}
|
|
for _, item := range in.Helper {
|
|
out.Helper = append(out.Helper, requestdto.DutyRosterCrewInput{ID: item.ID})
|
|
}
|
|
for _, item := range in.OtherPerson {
|
|
out.OtherPerson = append(out.OtherPerson, requestdto.DutyRosterOtherPersonInput{
|
|
Name: item.Name,
|
|
MobilePhone: item.MobilePhone,
|
|
Email: item.Email,
|
|
})
|
|
}
|
|
return &out
|
|
}
|
|
|
|
func takeoverRosterDetailResponse(in requestdto.DutyRosterDetailsInput) responsedto.TakeoverDutyRosterDetailsResponse {
|
|
out := responsedto.TakeoverDutyRosterDetailsResponse{
|
|
Pilot: make([]responsedto.TakeoverDutyRosterPilotCrew, 0, len(in.Pilot)),
|
|
Doctor: make([]responsedto.TakeoverDutyRosterCrew, 0, len(in.Doctor)),
|
|
Rescuer: make([]responsedto.TakeoverDutyRosterCrew, 0, len(in.Rescuer)),
|
|
Helper: make([]responsedto.TakeoverDutyRosterCrew, 0, len(in.Helper)),
|
|
OtherPerson: make([]responsedto.TakeoverDutyRosterCrew, 0, len(in.OtherPerson)),
|
|
}
|
|
for _, item := range in.Pilot {
|
|
out.Pilot = append(out.Pilot, responsedto.TakeoverDutyRosterPilotCrew{
|
|
ID: item.ID,
|
|
CrewType: item.CrewType,
|
|
FlightInstructor: takeoverBoolPtr(item.FlightInstructor),
|
|
LineChecker: takeoverBoolPtr(item.LineChecker),
|
|
Supervisor: takeoverBoolPtr(item.Supervisor),
|
|
Examiner: takeoverBoolPtr(item.Examiner),
|
|
CoPilot: takeoverBoolPtr(item.CoPilot),
|
|
Role: "pilot",
|
|
})
|
|
}
|
|
for _, item := range in.Doctor {
|
|
out.Doctor = append(out.Doctor, responsedto.TakeoverDutyRosterCrew{
|
|
ID: item.ID,
|
|
Role: "doctor",
|
|
})
|
|
}
|
|
for _, item := range in.Rescuer {
|
|
out.Rescuer = append(out.Rescuer, responsedto.TakeoverDutyRosterCrew{
|
|
ID: item.ID,
|
|
Role: "rescuer",
|
|
})
|
|
}
|
|
for _, item := range in.Helper {
|
|
out.Helper = append(out.Helper, responsedto.TakeoverDutyRosterCrew{
|
|
ID: item.ID,
|
|
Role: "helper",
|
|
})
|
|
}
|
|
for _, item := range in.OtherPerson {
|
|
out.OtherPerson = append(out.OtherPerson, responsedto.TakeoverDutyRosterCrew{
|
|
Name: item.Name,
|
|
MobilePhone: item.MobilePhone,
|
|
Email: item.Email,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func takeoverDutyRosterDetailsFromRosterDetails(in responsedto.DutyRosterDetailsResponse) responsedto.TakeoverDutyRosterDetailsResponse {
|
|
out := responsedto.TakeoverDutyRosterDetailsResponse{
|
|
Pilot: make([]responsedto.TakeoverDutyRosterPilotCrew, 0, len(in.Pilot)),
|
|
Doctor: make([]responsedto.TakeoverDutyRosterCrew, 0, len(in.Doctor)),
|
|
Rescuer: make([]responsedto.TakeoverDutyRosterCrew, 0, len(in.Rescuer)),
|
|
Helper: make([]responsedto.TakeoverDutyRosterCrew, 0, len(in.Helper)),
|
|
OtherPerson: make([]responsedto.TakeoverDutyRosterCrew, 0, len(in.OtherPerson)),
|
|
}
|
|
for _, item := range in.Pilot {
|
|
out.Pilot = append(out.Pilot, responsedto.TakeoverDutyRosterPilotCrew{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Role: item.Role,
|
|
CrewType: item.CrewType,
|
|
FlightInstructor: item.FlightInstructor,
|
|
LineChecker: item.LineChecker,
|
|
Supervisor: item.Supervisor,
|
|
Examiner: item.Examiner,
|
|
CoPilot: item.CoPilot,
|
|
})
|
|
}
|
|
for _, item := range in.Doctor {
|
|
out.Doctor = append(out.Doctor, responsedto.TakeoverDutyRosterCrew{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Role: item.Role,
|
|
MobilePhone: item.MobilePhone,
|
|
Email: item.Email,
|
|
})
|
|
}
|
|
for _, item := range in.Rescuer {
|
|
out.Rescuer = append(out.Rescuer, responsedto.TakeoverDutyRosterCrew{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Role: item.Role,
|
|
MobilePhone: item.MobilePhone,
|
|
Email: item.Email,
|
|
})
|
|
}
|
|
for _, item := range in.Helper {
|
|
out.Helper = append(out.Helper, responsedto.TakeoverDutyRosterCrew{
|
|
ID: item.ID,
|
|
Name: item.Name,
|
|
Role: item.Role,
|
|
MobilePhone: item.MobilePhone,
|
|
Email: item.Email,
|
|
})
|
|
}
|
|
for _, item := range in.OtherPerson {
|
|
out.OtherPerson = append(out.OtherPerson, responsedto.TakeoverDutyRosterCrew{
|
|
Name: item.Name,
|
|
MobilePhone: item.MobilePhone,
|
|
Email: item.Email,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func takeoverBoolPtr(v bool) *bool {
|
|
if !v {
|
|
return nil
|
|
}
|
|
out := true
|
|
return &out
|
|
}
|
|
|
|
func loadTakeoverRosterCrewRows(ctx context.Context, db *gorm.DB, takeoverID []byte) ([]takeover.TakeoverRosterCrew, error) {
|
|
rows := make([]takeover.TakeoverRosterCrew, 0)
|
|
if err := db.WithContext(ctx).
|
|
Where("takeover_id = ? AND deleted_at IS NULL", takeoverID).
|
|
Preload("User").
|
|
Order("created_at ASC").
|
|
Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func loadTakeoverOtherPeopleRows(ctx context.Context, db *gorm.DB, takeoverID []byte) ([]takeover.TakeoverOtherPerson, error) {
|
|
rows := make([]takeover.TakeoverOtherPerson, 0)
|
|
if err := db.WithContext(ctx).
|
|
Where("takeover_id = ? AND deleted_at IS NULL", takeoverID).
|
|
Order("created_at ASC").
|
|
Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func buildDutyRosterResourceFromTakeoverRows(resource responsedto.DutyRosterResource, rows []takeover.TakeoverRosterCrew, flightRow *flight.Flight) responsedto.DutyRosterResource {
|
|
for i := range rows {
|
|
item := rows[i]
|
|
if item.User != nil && len(item.User.FirstName+item.User.LastName) > 0 {
|
|
item.NameLabel = item.User.FirstName + " " + item.User.LastName
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(item.RoleCode)) {
|
|
case "pilot":
|
|
resource.Attributes.Detail.Pilot = append(resource.Attributes.Detail.Pilot, responsedto.DutyRosterPilotCrew{
|
|
ID: idString(item.UserID),
|
|
Name: item.NameLabel,
|
|
Role: "pilot",
|
|
MobilePhone: item.MobilePhone,
|
|
Email: item.Email,
|
|
CrewType: item.CrewType,
|
|
FlightInstructor: boolPtr(item.FlightInstructor),
|
|
LineChecker: boolPtr(item.LineChecker),
|
|
Supervisor: boolPtr(item.Supervisor),
|
|
Examiner: boolPtr(item.Examiner),
|
|
CoPilot: boolPtr(item.CoPilot),
|
|
})
|
|
case "doctor":
|
|
resource.Attributes.Detail.Doctor = append(resource.Attributes.Detail.Doctor, responsedto.DutyRosterCrew{
|
|
ID: firstNonEmptyString(idString(item.UserID), idString(item.ID)),
|
|
Name: item.NameLabel,
|
|
Role: "doctor",
|
|
MobilePhone: item.MobilePhone,
|
|
Email: item.Email,
|
|
})
|
|
case "rescuer":
|
|
resource.Attributes.Detail.Rescuer = append(resource.Attributes.Detail.Rescuer, responsedto.DutyRosterCrew{
|
|
ID: firstNonEmptyString(idString(item.UserID), idString(item.ID)),
|
|
Name: item.NameLabel,
|
|
Role: "rescuer",
|
|
MobilePhone: item.MobilePhone,
|
|
Email: item.Email,
|
|
})
|
|
case "helper":
|
|
resource.Attributes.Detail.Helper = append(resource.Attributes.Detail.Helper, responsedto.DutyRosterCrew{
|
|
ID: firstNonEmptyString(idString(item.UserID), idString(item.ID)),
|
|
Name: item.NameLabel,
|
|
Role: "helper",
|
|
MobilePhone: item.MobilePhone,
|
|
Email: item.Email,
|
|
})
|
|
}
|
|
}
|
|
if flightRow != nil {
|
|
resource.ID = idString(flightRow.TakeoverAcID)
|
|
resource.Attributes.FlightID = idString(flightRow.ID)
|
|
resource.Attributes.DutyDate = calendarDateString(flightRow.Date)
|
|
}
|
|
return resource
|
|
}
|
|
|
|
func buildDutyRosterResourceFromOtherPeople(resource responsedto.DutyRosterResource, rows []takeover.TakeoverOtherPerson) responsedto.DutyRosterResource {
|
|
for i := range rows {
|
|
resource.Attributes.Detail.OtherPerson = append(resource.Attributes.Detail.OtherPerson, responsedto.DutyRosterCrew{
|
|
ID: idString(rows[i].ID),
|
|
Name: rows[i].Name,
|
|
Role: "other_person",
|
|
MobilePhone: rows[i].MobilePhone,
|
|
Email: rows[i].Email,
|
|
})
|
|
}
|
|
return resource
|
|
}
|
|
|
|
func softDeleteTakeoverRosterChildren(ctx context.Context, db *gorm.DB, takeoverID []byte, deletedBy []byte) error {
|
|
now := time.Now().UTC()
|
|
if err := db.WithContext(ctx).Model(&takeover.TakeoverRosterCrew{}).
|
|
Where("takeover_id = ? AND deleted_at IS NULL", takeoverID).
|
|
Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy}).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := db.WithContext(ctx).Model(&takeover.TakeoverOtherPerson{}).
|
|
Where("takeover_id = ? AND deleted_at IS NULL", takeoverID).
|
|
Updates(map[string]any{"deleted_at": now, "deleted_by": deletedBy, "updated_by": deletedBy}).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListTakeovers godoc
|
|
// @Summary List takeovers
|
|
// @Description Return composite takeover records built from flight, reserve AC, duty roster, and inspection. The inspection payload includes `before`, `prepare`, and `fleet_status_file`.
|
|
// @Tags Flight - Takeovers
|
|
// @Produce json
|
|
// @Success 200 {object} map[string]any
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/takeovers/get-all [get]
|
|
func (h *TakeoverHandler) ListTakeovers(c *fiber.Ctx) error {
|
|
if h.takeoverAc == nil || h.flight == nil {
|
|
logTakeoverError(c, "ListTakeovers", errors.New("takeover service is not configured"))
|
|
return writeTakeoverErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{Status: "500", Title: "Get failed", Detail: "takeover service is not configured"}})
|
|
}
|
|
rows, _, err := h.takeoverAc.List(c.UserContext(), 100, 0)
|
|
if err != nil {
|
|
logTakeoverError(c, "ListTakeovers", err)
|
|
return writeTakeoverErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Get failed", Detail: safeInternalDetail()}})
|
|
}
|
|
takeoverIDs := make([][]byte, 0, len(rows))
|
|
for i := range rows {
|
|
takeoverIDs = append(takeoverIDs, rows[i].ID)
|
|
}
|
|
flights, err := h.flight.ListByTakeoverAcIDs(c.UserContext(), takeoverIDs)
|
|
if err != nil {
|
|
logTakeoverError(c, "ListTakeovers", err)
|
|
return writeTakeoverErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Get failed", Detail: safeInternalDetail()}})
|
|
}
|
|
flightByTakeoverID := make(map[string]flight.Flight, len(flights))
|
|
for i := range flights {
|
|
flightByTakeoverID[idString(flights[i].TakeoverAcID)] = flights[i]
|
|
}
|
|
out := make([]responsedto.TakeoverResource, 0, len(rows))
|
|
for i := range rows {
|
|
if flightRow, ok := flightByTakeoverID[idString(rows[i].ID)]; ok {
|
|
item, err := h.buildTakeoverResponseFromRows(c, &rows[i], &flightRow)
|
|
if err != nil || item == nil {
|
|
continue
|
|
}
|
|
out = append(out, item.Data)
|
|
continue
|
|
}
|
|
item, err := h.buildTakeoverResponse(c, rows[i].ID)
|
|
if err != nil || item == nil {
|
|
continue
|
|
}
|
|
out = append(out, item.Data)
|
|
}
|
|
return response.WriteWithMeta(c, fiber.StatusOK, out, map[string]any{"total": len(out)})
|
|
}
|
|
|
|
// GetTakeover godoc
|
|
// @Summary Get takeover by flight UUID
|
|
// @Tags Flight - Takeovers
|
|
// @Produce json
|
|
// @Param id path string true "Flight UUID"
|
|
// @Success 200 {object} responsedto.TakeoverResponse
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Failure 404 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/takeovers/get/{id} [get]
|
|
func (h *TakeoverHandler) GetTakeover(c *fiber.Ctx) error {
|
|
idRaw := strings.TrimSpace(c.Params("id"))
|
|
id, err := uuidv7.ParseString(idRaw)
|
|
if err != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}}})
|
|
}
|
|
out, err := h.buildTakeoverResponse(c, id)
|
|
if err != nil {
|
|
logTakeoverError(c, "GetTakeover", err)
|
|
return writeTakeoverErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Get failed", Detail: safeInternalDetail()}})
|
|
}
|
|
if out == nil {
|
|
return writeTakeoverErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{Status: "404", Title: "Not found", Detail: "takeover not found"}})
|
|
}
|
|
return response.Write(c, fiber.StatusOK, out.Data)
|
|
}
|
|
|
|
// UpdateTakeover godoc
|
|
// @Summary Replace takeover by flight UUID
|
|
// @Tags Flight - Takeovers
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param id path string true "Flight UUID"
|
|
// @Param request body requestdto.TakeoverUpdateRequest true "Update takeover request"
|
|
// @Success 200 {object} responsedto.TakeoverResponse
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Failure 404 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/takeovers/update/{id} [patch]
|
|
func (h *TakeoverHandler) UpdateTakeover(c *fiber.Ctx) error {
|
|
takeoverSvc := h.takeoverService()
|
|
if h == nil || takeoverSvc == nil {
|
|
logTakeoverError(c, "UpdateTakeover", errors.New("takeover service is not configured"))
|
|
return writeTakeoverErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{Status: "500", Title: "Update failed", Detail: "takeover service is not configured"}})
|
|
}
|
|
idRaw := strings.TrimSpace(c.Params("id"))
|
|
id, err := uuidv7.ParseString(idRaw)
|
|
if err != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}}})
|
|
}
|
|
existingFlight, err := h.flight.GetByID(c.UserContext(), id)
|
|
if err != nil || existingFlight == nil {
|
|
return writeTakeoverErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{Status: "404", Title: "Not found", Detail: "takeover not found"}})
|
|
}
|
|
var req requestdto.TakeoverUpdateRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
logTakeoverError(c, "UpdateTakeover", err)
|
|
return writeTakeoverErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail()}})
|
|
}
|
|
if errs := h.validate.ValidateStruct(req); errs != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, errs)
|
|
}
|
|
var errObj *jsonapi.ErrorObject
|
|
var baseID []byte
|
|
if req.Data.Attributes.BaseID != nil && strings.TrimSpace(*req.Data.Attributes.BaseID) != "" {
|
|
baseID, errObj = parseTakeoverUUID(*req.Data.Attributes.BaseID, "/data/attributes/base_id")
|
|
if errObj != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
|
}
|
|
}
|
|
var dutyDate time.Time
|
|
if req.Data.Attributes.DutyDate != nil && strings.TrimSpace(*req.Data.Attributes.DutyDate) != "" {
|
|
dutyDate, errObj = parseTakeoverDate(*req.Data.Attributes.DutyDate)
|
|
if errObj != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
|
}
|
|
}
|
|
var helicopterID []byte
|
|
if req.Data.Attributes.AircraftID != nil && strings.TrimSpace(*req.Data.Attributes.AircraftID) != "" {
|
|
helicopterID, errObj = parseTakeoverUUID(*req.Data.Attributes.AircraftID, "/data/attributes/helicopter_id")
|
|
if errObj != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
|
}
|
|
}
|
|
var inspection *requestdto.ReserveAcInspectionCreateAttributes
|
|
if req.Data.Attributes.Inspection != nil {
|
|
inspection = req.Data.Attributes.Inspection
|
|
}
|
|
filesAdd := takeoverFileCreateInputs(req.Data.Attributes.Files, req.Data.Attributes.FilesAdd)
|
|
filesRemove := req.Data.Attributes.FilesRemove
|
|
filesReplace := req.Data.Attributes.FilesReplace
|
|
if len(filesReplace) > 0 && (len(filesAdd) > 0 || len(filesRemove) > 0) {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "files_replace cannot be combined with files, files_add, or files_remove",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/files_replace"},
|
|
}})
|
|
}
|
|
if appErr := h.helicopterGroundedError(c.UserContext(), helicopterID, h.existingTakeoverHelicopterID(c.UserContext(), existingFlight)); appErr != nil {
|
|
return apperrorsx.Write(c, appErr)
|
|
}
|
|
if len(existingFlight.ReserveAcID) == 16 && h.hasAfterFlightInspection(c.UserContext(), existingFlight.ReserveAcID) {
|
|
if req.Data.Attributes.BaseID != nil || req.Data.Attributes.Bases != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "base_id cannot be changed after after_flight_inspection exists",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"},
|
|
}})
|
|
}
|
|
if req.Data.Attributes.Detail != nil || req.Data.Attributes.RosterDetail != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "roster_detail cannot be changed after after_flight_inspection exists",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/roster_detail"},
|
|
}})
|
|
}
|
|
}
|
|
out, err := takeoverSvc.Replace(c.UserContext(), existingFlight.ID, service.TakeoverCreateInput{
|
|
BaseID: baseID,
|
|
HasBaseID: baseID != nil,
|
|
DutyDate: dutyDate,
|
|
HasDutyDate: req.Data.Attributes.DutyDate != nil && strings.TrimSpace(*req.Data.Attributes.DutyDate) != "",
|
|
Notes: req.Data.Attributes.Notes,
|
|
RosterDetail: takeoverRosterDetailInput(req.Data.Attributes.Detail),
|
|
HasRosterDetail: req.Data.Attributes.Detail != nil,
|
|
HelicopterID: helicopterID,
|
|
HasHelicopterID: helicopterID != nil,
|
|
Inspection: inspection,
|
|
HasInspection: inspection != nil,
|
|
ActorUserID: actorUserID(c),
|
|
})
|
|
if err != nil {
|
|
logTakeoverError(c, "UpdateTakeover", err)
|
|
if appErr := takeoverAppErrorFromServiceErr(err); appErr != nil {
|
|
return apperrorsx.Write(c, appErr)
|
|
}
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverUpdateFailed).WithCause(err))
|
|
}
|
|
if err := h.applyTakeoverUpdateFiles(c, takeoverSvc, existingFlight.TakeoverAcID, filesAdd, filesRemove, filesReplace); err != nil {
|
|
return err
|
|
}
|
|
responseTakeoverID := out.TakeoverAcID
|
|
if len(responseTakeoverID) != 16 {
|
|
responseTakeoverID = existingFlight.TakeoverAcID
|
|
}
|
|
outResp, respErr := h.buildTakeoverResponse(c, responseTakeoverID)
|
|
if respErr != nil {
|
|
logTakeoverError(c, "UpdateTakeover.buildResponse", respErr)
|
|
return writeTakeoverErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Update failed", Detail: respErr.Error()}})
|
|
}
|
|
if outResp == nil {
|
|
return writeTakeoverErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{Status: "500", Title: "Update failed", Detail: "takeover response not available"}})
|
|
}
|
|
return response.Write(c, fiber.StatusOK, outResp.Data)
|
|
}
|
|
|
|
func takeoverFileCreateInputs(primary, fallback []requestdto.TakeoverCreateFileInput) []requestdto.TakeoverCreateFileInput {
|
|
total := len(primary) + len(fallback)
|
|
if total == 0 {
|
|
return nil
|
|
}
|
|
out := make([]requestdto.TakeoverCreateFileInput, 0, total)
|
|
out = append(out, primary...)
|
|
out = append(out, fallback...)
|
|
return out
|
|
}
|
|
|
|
func (h *TakeoverHandler) applyTakeoverUpdateFiles(
|
|
c *fiber.Ctx,
|
|
takeoverSvc *service.TakeoverService,
|
|
takeoverID []byte,
|
|
filesAdd []requestdto.TakeoverCreateFileInput,
|
|
filesRemove []string,
|
|
filesReplace []requestdto.TakeoverCreateFileInput,
|
|
) error {
|
|
if len(filesAdd) == 0 && len(filesRemove) == 0 && len(filesReplace) == 0 {
|
|
return nil
|
|
}
|
|
if h.fileManager == nil {
|
|
logTakeoverError(c, "TakeoverFiles.FileManager", errors.New("file manager service is not configured"))
|
|
return writeTakeoverErrors(c, fiber.StatusServiceUnavailable, []jsonapi.ErrorObject{{Status: "503", Title: "Update failed", Detail: "file manager service is not configured"}})
|
|
}
|
|
|
|
current, err := takeoverSvc.GetByID(c.UserContext(), takeoverID)
|
|
if err != nil {
|
|
logTakeoverError(c, "TakeoverFiles.GetByID", err)
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverUpdateFailed).WithCause(err))
|
|
}
|
|
if current == nil {
|
|
return writeTakeoverErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{Status: "404", Title: "Not found", Detail: "takeover not found"}})
|
|
}
|
|
|
|
existingFiles := make(map[string]takeover.TakeoverFile, len(current.Files))
|
|
for i := range current.Files {
|
|
existingFiles[idString(current.Files[i].FileAttachmentID)] = current.Files[i]
|
|
}
|
|
|
|
if len(filesReplace) > 0 {
|
|
filesAdd = filesReplace
|
|
filesRemove = filesRemove[:0]
|
|
for i := range current.Files {
|
|
filesRemove = append(filesRemove, idString(current.Files[i].FileAttachmentID))
|
|
}
|
|
}
|
|
|
|
for i := range filesRemove {
|
|
attachmentID, errObj := parseTakeoverUUID(filesRemove[i], "/data/attributes/files_remove/"+strconv.Itoa(i))
|
|
if errObj != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
|
}
|
|
attachmentKey := idString(attachmentID)
|
|
if _, ok := existingFiles[attachmentKey]; !ok {
|
|
return writeTakeoverErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
|
|
Status: "404",
|
|
Title: "Not found",
|
|
Detail: "takeover file not found",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/files_remove/" + strconv.Itoa(i)},
|
|
}})
|
|
}
|
|
if err := takeoverSvc.DetachFile(c.UserContext(), takeoverID, attachmentID); err != nil {
|
|
logTakeoverError(c, "TakeoverFiles.DetachFile", err)
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return writeTakeoverErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
|
|
Status: "404",
|
|
Title: "Not found",
|
|
Detail: "takeover file not found",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/files_remove/" + strconv.Itoa(i)},
|
|
}})
|
|
}
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverUpdateFailed).WithCause(err))
|
|
}
|
|
if err := h.fileManager.DeleteAttachment(c.UserContext(), attachmentID); err != nil {
|
|
logTakeoverError(c, "TakeoverFiles.DeleteAttachment", err)
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverUpdateFailed).WithCause(err))
|
|
}
|
|
}
|
|
|
|
if len(filesAdd) == 0 {
|
|
return nil
|
|
}
|
|
return h.attachTakeoverCreateFiles(c, takeoverSvc, takeoverID, filesAdd)
|
|
}
|
|
|
|
func (h *TakeoverHandler) attachTakeoverCreateFiles(c *fiber.Ctx, takeoverSvc *service.TakeoverService, takeoverID []byte, files []requestdto.TakeoverCreateFileInput) error {
|
|
if len(files) == 0 {
|
|
return nil
|
|
}
|
|
if h.fileManager == nil {
|
|
logTakeoverError(c, "TakeoverFiles.FileManager", errors.New("file manager service is not configured"))
|
|
return writeTakeoverErrors(c, fiber.StatusServiceUnavailable, []jsonapi.ErrorObject{{Status: "503", Title: "Create failed", Detail: "file manager service is not configured"}})
|
|
}
|
|
for i := range files {
|
|
item := files[i]
|
|
var fileID []byte
|
|
if strings.TrimSpace(item.FileID) != "" {
|
|
parsedFileID, errObj := parseTakeoverUUID(item.FileID, "/data/attributes/files/"+strconv.Itoa(i)+"/file_id")
|
|
if errObj != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
|
}
|
|
fileID = parsedFileID
|
|
} else {
|
|
if strings.TrimSpace(item.UploadIntentUUID) == "" {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
|
|
Status: "422",
|
|
Title: "Validation error",
|
|
Detail: "file_id or upload_intent_uuid is required",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/files/" + strconv.Itoa(i)},
|
|
}})
|
|
}
|
|
uploadIntentID, errObj := parseTakeoverUUID(item.UploadIntentUUID, "/data/attributes/files/"+strconv.Itoa(i)+"/upload_intent_uuid")
|
|
if errObj != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
|
}
|
|
createdFile, createErr := h.fileManager.CreateFileNodeFromUploadIntent(c.UserContext(), filemanager.CreateFileFromUploadIntentParams{
|
|
UploadIntentID: uploadIntentID,
|
|
Name: strings.TrimSpace(item.Name),
|
|
ActorID: actorUserID(c),
|
|
})
|
|
if createErr != nil {
|
|
logTakeoverError(c, "TakeoverFiles.CreateFile", createErr)
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverCreateFailed).WithCause(createErr))
|
|
}
|
|
fileID = createdFile.ID
|
|
}
|
|
attachment, attachErr := h.fileManager.CreateAttachment(c.UserContext(), filemanager.CreateAttachmentParams{
|
|
FileID: fileID,
|
|
RefType: "takeover",
|
|
RefID: idString(takeoverID),
|
|
SortOrder: 0,
|
|
IsPrimary: false,
|
|
ActorID: actorUserID(c),
|
|
})
|
|
if attachErr != nil {
|
|
logTakeoverError(c, "TakeoverFiles.CreateAttachment", attachErr)
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverCreateFailed).WithCause(attachErr))
|
|
}
|
|
if err := takeoverSvc.AttachFile(c.UserContext(), takeoverID, attachment.ID); err != nil {
|
|
logTakeoverError(c, "TakeoverFiles.AttachFile", err)
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverCreateFailed).WithCause(err))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (h *TakeoverHandler) attachTakeoverEditedTemplateFiles(c *fiber.Ctx, takeoverSvc *service.TakeoverService, takeoverID []byte, fileIDs []string) error {
|
|
if len(fileIDs) == 0 {
|
|
return nil
|
|
}
|
|
if h.fileManager == nil {
|
|
logTakeoverError(c, "TakeoverEditedTemplateFiles.FileManager", errors.New("file manager service is not configured"))
|
|
return writeTakeoverErrors(c, fiber.StatusServiceUnavailable, []jsonapi.ErrorObject{{Status: "503", Title: "Create failed", Detail: "file manager service is not configured"}})
|
|
}
|
|
for i := range fileIDs {
|
|
fileID, errObj := parseTakeoverUUID(fileIDs[i], "/data/attributes/edited_template_file_ids/"+strconv.Itoa(i))
|
|
if errObj != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
|
|
}
|
|
row, err := h.fileManager.GetFileByIDAny(c.UserContext(), fileID)
|
|
if err != nil {
|
|
logTakeoverError(c, "TakeoverEditedTemplateFiles.GetFile", err)
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverCreateFailed).WithCause(err))
|
|
}
|
|
if row == nil {
|
|
return writeTakeoverErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
|
|
Status: "404",
|
|
Title: "Not found",
|
|
Detail: "edited template file not found",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/edited_template_file_ids/" + strconv.Itoa(i)},
|
|
}})
|
|
}
|
|
if !takeoverEditedTemplateFileBelongsToTakeover(row, takeoverID) {
|
|
return writeTakeoverErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
|
|
Status: "404",
|
|
Title: "Not found",
|
|
Detail: "edited template file not found",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/edited_template_file_ids/" + strconv.Itoa(i)},
|
|
}})
|
|
}
|
|
if !bytes.Equal(row.CreatedBy, actorUserID(c)) {
|
|
return writeTakeoverErrors(c, fiber.StatusForbidden, []jsonapi.ErrorObject{{
|
|
Status: "403",
|
|
Title: "Forbidden",
|
|
Detail: "forbidden access",
|
|
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/edited_template_file_ids/" + strconv.Itoa(i)},
|
|
}})
|
|
}
|
|
attachment, attachErr := h.fileManager.CreateAttachment(c.UserContext(), filemanager.CreateAttachmentParams{
|
|
FileID: fileID,
|
|
RefType: "takeover",
|
|
RefID: idString(takeoverID),
|
|
SortOrder: 0,
|
|
IsPrimary: false,
|
|
ActorID: actorUserID(c),
|
|
})
|
|
if attachErr != nil {
|
|
logTakeoverError(c, "TakeoverEditedTemplateFiles.CreateAttachment", attachErr)
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverCreateFailed).WithCause(attachErr))
|
|
}
|
|
if err := takeoverSvc.AttachFile(c.UserContext(), takeoverID, attachment.ID); err != nil {
|
|
logTakeoverError(c, "TakeoverEditedTemplateFiles.AttachFile", err)
|
|
return apperrorsx.Write(c, apperrorsx.New(apperrorsx.ErrTakeoverCreateFailed).WithCause(err))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func takeoverEditedTemplateFileBelongsToTakeover(row *filemanager.File, takeoverID []byte) bool {
|
|
if row == nil {
|
|
return false
|
|
}
|
|
if len(row.TakeoverID) == 0 {
|
|
return true
|
|
}
|
|
return len(row.TakeoverID) == 16 && len(takeoverID) == 16 && bytes.Equal(row.TakeoverID, takeoverID)
|
|
}
|
|
|
|
// DeleteTakeover godoc
|
|
// @Summary Delete takeover by flight UUID
|
|
// @Tags Flight - Takeovers
|
|
// @Produce json
|
|
// @Param id path string true "Flight UUID"
|
|
// @Success 200 {object} map[string]any
|
|
// @Failure 400 {object} jsonapi.ErrorDocument
|
|
// @Failure 404 {object} jsonapi.ErrorDocument
|
|
// @Router /api/v1/takeovers/delete/{id} [delete]
|
|
func (h *TakeoverHandler) DeleteTakeover(c *fiber.Ctx) error {
|
|
idRaw := strings.TrimSpace(c.Params("id"))
|
|
id, err := uuidv7.ParseString(idRaw)
|
|
if err != nil {
|
|
return writeTakeoverErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}}})
|
|
}
|
|
flightRow, err := h.flight.GetByID(c.UserContext(), id)
|
|
if err != nil || flightRow == nil {
|
|
if err != nil {
|
|
logTakeoverError(c, "DeleteTakeover", err)
|
|
}
|
|
return writeTakeoverErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{Status: "404", Title: "Not found", Detail: "takeover not found"}})
|
|
}
|
|
takeoverAcID := flightRow.TakeoverAcID
|
|
if len(takeoverAcID) != 16 {
|
|
return writeTakeoverErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{Status: "404", Title: "Not found", Detail: "takeover not found"}})
|
|
}
|
|
if err := h.flight.Delete(c.UserContext(), flightRow.ID, actorUserID(c)); err != nil {
|
|
logTakeoverError(c, "DeleteTakeover", err)
|
|
return writeTakeoverErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Delete failed", Detail: safeInternalDetail()}})
|
|
}
|
|
if row, _ := h.reserveAc.GetByID(c.UserContext(), flightRow.ReserveAcID); row != nil {
|
|
_ = h.reserveAc.Delete(c.UserContext(), row.ID, actorUserID(c))
|
|
}
|
|
if h.db != nil && len(takeoverAcID) == 16 {
|
|
_ = softDeleteTakeoverRosterChildren(c.UserContext(), h.db, takeoverAcID, actorUserID(c))
|
|
}
|
|
if h.takeoverAc != nil {
|
|
_ = h.takeoverAc.Delete(c.UserContext(), takeoverAcID, actorUserID(c))
|
|
}
|
|
return response.Write(c, fiber.StatusOK, map[string]any{"data": map[string]any{"deleted": true, "takeover_ac_id": idString(takeoverAcID), "flight_id": idString(flightRow.ID)}})
|
|
}
|
|
|
|
func (h *TakeoverHandler) buildTakeoverResponse(c *fiber.Ctx, flightID []byte) (*responsedto.TakeoverResponse, error) {
|
|
if h.takeoverAc == nil {
|
|
return nil, errors.New("takeover service is not configured")
|
|
}
|
|
takeoverRow, err := h.takeoverAc.GetByID(c.UserContext(), flightID)
|
|
if err != nil {
|
|
logTakeoverError(c, "buildTakeoverResponse.takeoverAc.GetByID", err)
|
|
return nil, err
|
|
}
|
|
if takeoverRow == nil && h.flight != nil {
|
|
flightRow, ferr := h.flight.GetByID(c.UserContext(), flightID)
|
|
if ferr != nil || flightRow == nil || len(flightRow.TakeoverAcID) != 16 {
|
|
return nil, nil
|
|
}
|
|
takeoverRow, err = h.takeoverAc.GetByID(c.UserContext(), flightRow.TakeoverAcID)
|
|
if err != nil || takeoverRow == nil {
|
|
if err != nil {
|
|
logTakeoverError(c, "buildTakeoverResponse.takeoverAc.GetByIDFallback", err)
|
|
}
|
|
return nil, err
|
|
}
|
|
}
|
|
if takeoverRow == nil {
|
|
return nil, nil
|
|
}
|
|
var flightRow *flight.Flight
|
|
if h.flight != nil {
|
|
flightRow, _ = h.flight.GetByTakeoverAcID(c.UserContext(), takeoverRow.ID)
|
|
}
|
|
reserveRow := h.resolveTakeoverReserveRow(c, takeoverRow, flightRow)
|
|
inspectionSummary := h.resolveTakeoverInspectionSummary(c, reserveRow)
|
|
dutyResource := h.resolveTakeoverDutyResource(c, flightRow)
|
|
attrs := h.buildTakeoverResponseAttributes(c, takeoverRow, flightRow, reserveRow, inspectionSummary, dutyResource)
|
|
return &responsedto.TakeoverResponse{
|
|
Data: responsedto.TakeoverResource{
|
|
Type: "takeover",
|
|
Attributes: attrs,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (h *TakeoverHandler) buildTakeoverResponseFromRows(c *fiber.Ctx, takeoverRow *takeover.TakeoverAc, flightRow *flight.Flight) (*responsedto.TakeoverResponse, error) {
|
|
if takeoverRow == nil || flightRow == nil {
|
|
return nil, nil
|
|
}
|
|
reserveRow := h.resolveTakeoverReserveRow(c, takeoverRow, flightRow)
|
|
inspectionSummary := h.resolveTakeoverInspectionSummary(c, reserveRow)
|
|
dutyResource := h.resolveTakeoverDutyResource(c, flightRow)
|
|
attrs := h.buildTakeoverResponseAttributes(c, takeoverRow, flightRow, reserveRow, inspectionSummary, dutyResource)
|
|
return &responsedto.TakeoverResponse{
|
|
Data: responsedto.TakeoverResource{
|
|
Type: "takeover",
|
|
Attributes: attrs,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (h *TakeoverHandler) resolveTakeoverReserveRow(c *fiber.Ctx, takeoverRow *takeover.TakeoverAc, flightRow *flight.Flight) *reserveac.ReserveAc {
|
|
if takeoverRow != nil && takeoverRow.ReserveAc != nil {
|
|
return takeoverRow.ReserveAc
|
|
}
|
|
if flightRow == nil || h == nil || h.reserveAc == nil || len(flightRow.ReserveAcID) != 16 {
|
|
return nil
|
|
}
|
|
reserveRow, _ := h.reserveAc.GetByID(c.UserContext(), flightRow.ReserveAcID)
|
|
return reserveRow
|
|
}
|
|
|
|
func (h *TakeoverHandler) resolveTakeoverInspectionSummary(c *fiber.Ctx, reserveRow *reserveac.ReserveAc) *shareddto.ReserveAcInspectionSummary {
|
|
if reserveRow == nil {
|
|
return nil
|
|
}
|
|
summary, _ := h.buildCreateInspectionSummary(c, reserveRow.InspectionID, reserveRow.AircraftID)
|
|
return summary
|
|
}
|
|
|
|
func (h *TakeoverHandler) resolveTakeoverDutyResource(c *fiber.Ctx, flightRow *flight.Flight) responsedto.DutyRosterResource {
|
|
dutyResource := responsedto.DutyRosterResource{
|
|
Type: "duty_roster",
|
|
Attributes: responsedto.DutyRosterAttributes{
|
|
Bases: responsedto.DutyRosterBaseInfo{},
|
|
ShiftTime: responsedto.DutyRosterShiftTime{},
|
|
Detail: responsedto.DutyRosterDetailsResponse{Pilot: []responsedto.DutyRosterPilotCrew{}, Doctor: []responsedto.DutyRosterCrew{}, Rescuer: []responsedto.DutyRosterCrew{}, Helper: []responsedto.DutyRosterCrew{}, OtherPerson: []responsedto.DutyRosterCrew{}},
|
|
},
|
|
}
|
|
if h == nil || h.db == nil || flightRow == nil || len(flightRow.TakeoverAcID) != 16 {
|
|
return dutyResource
|
|
}
|
|
if rows, err := loadTakeoverRosterCrewRows(c.UserContext(), h.db, flightRow.TakeoverAcID); err == nil {
|
|
dutyResource = buildDutyRosterResourceFromTakeoverRows(dutyResource, rows, flightRow)
|
|
}
|
|
if others, err := loadTakeoverOtherPeopleRows(c.UserContext(), h.db, flightRow.TakeoverAcID); err == nil {
|
|
dutyResource = buildDutyRosterResourceFromOtherPeople(dutyResource, others)
|
|
}
|
|
if takeoverRow, err := h.takeoverAc.GetByID(c.UserContext(), flightRow.TakeoverAcID); err == nil && takeoverRow != nil {
|
|
shiftTime := h.resolveTakeoverShiftTime(c, takeoverRow.BaseID, flightRow.Date)
|
|
if takeoverRow.Base != nil {
|
|
dutyResource.Attributes.Bases = responsedto.DutyRosterBaseInfo{
|
|
ID: idString(takeoverRow.BaseID),
|
|
Type: "hems",
|
|
BaseName: strings.TrimSpace(takeoverRow.Base.BaseName),
|
|
BaseAbbreviation: strings.TrimSpace(takeoverRow.Base.BaseAbbreviation),
|
|
ShiftTime: shiftTime,
|
|
}
|
|
} else if baseInfo := h.takeoverBaseInfo(c, &dutyResource, takeoverRow.BaseID); baseInfo != nil {
|
|
dutyResource.Attributes.Bases = *baseInfo
|
|
}
|
|
if shiftTime != (responsedto.DutyRosterShiftTime{}) {
|
|
dutyResource.Attributes.ShiftTime = shiftTime
|
|
} else if len(takeoverRow.BaseID) == 16 && h.db != nil {
|
|
if shiftStart, shiftEnd, serr := service.ResolveBaseShiftRangeTx(h.db.WithContext(c.UserContext()), takeoverRow.BaseID, flightRow.Date); serr == nil {
|
|
dutyResource.Attributes.ShiftTime = responsedto.DutyRosterShiftTime{ShiftStart: shiftStart, ShiftEnd: shiftEnd}
|
|
}
|
|
}
|
|
}
|
|
normalizeTakeoverDutyRosterDetail(&dutyResource)
|
|
return dutyResource
|
|
}
|
|
|
|
func (h *TakeoverHandler) buildTakeoverResponseAttributes(
|
|
c *fiber.Ctx,
|
|
takeoverRow *takeover.TakeoverAc,
|
|
flightRow *flight.Flight,
|
|
reserveRow *reserveac.ReserveAc,
|
|
inspectionSummary *shareddto.ReserveAcInspectionSummary,
|
|
dutyResource responsedto.DutyRosterResource,
|
|
) responsedto.TakeoverResponseAttributes {
|
|
shiftTime := dutyResource.Attributes.ShiftTime
|
|
if shiftTime == (responsedto.DutyRosterShiftTime{}) {
|
|
var dutyDate time.Time
|
|
if flightRow != nil {
|
|
dutyDate = flightRow.Date
|
|
}
|
|
shiftTime = h.resolveTakeoverShiftTime(c, takeoverRow.BaseID, dutyDate)
|
|
}
|
|
attrs := responsedto.TakeoverResponseAttributes{
|
|
ID: idString(takeoverRow.ID),
|
|
ShiftStart: shiftTime.ShiftStart,
|
|
ShiftEnd: shiftTime.ShiftEnd,
|
|
BaseID: idString(takeoverRow.BaseID),
|
|
DutyDate: dutyResource.Attributes.DutyDate,
|
|
RosterDetail: takeoverDutyRosterDetailsFromRosterDetails(dutyResource.Attributes.Detail),
|
|
CreatedBy: uuidStringOrEmpty(takeoverRow.CreatedBy),
|
|
UpdatedBy: uuidStringOrEmpty(takeoverRow.UpdatedBy),
|
|
}
|
|
if !takeoverRow.CreatedAt.IsZero() {
|
|
attrs.CreatedAt = takeoverRow.CreatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if !takeoverRow.UpdatedAt.IsZero() {
|
|
attrs.UpdatedAt = takeoverRow.UpdatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
if takeoverRow.Base != nil {
|
|
attrs.Base = &responsedto.DutyRosterBaseInfo{
|
|
ID: idString(takeoverRow.BaseID),
|
|
Type: "hems",
|
|
BaseName: strings.TrimSpace(takeoverRow.Base.BaseName),
|
|
BaseAbbreviation: strings.TrimSpace(takeoverRow.Base.BaseAbbreviation),
|
|
ShiftTime: shiftTime,
|
|
}
|
|
} else {
|
|
attrs.Base = h.takeoverBaseInfo(c, &dutyResource, takeoverRow.BaseID)
|
|
}
|
|
if flightRow != nil {
|
|
attrs.FlightID = idString(flightRow.ID)
|
|
}
|
|
attrs.Notes = takeoverRow.Notes
|
|
if reserveRow != nil {
|
|
attrs.ReserveAcID = idString(reserveRow.ID)
|
|
attrs.FlightInspectionID = idString(reserveRow.InspectionID)
|
|
if reserveRow.Aircraft != nil {
|
|
attrs.HelicopterID = idString(reserveRow.Aircraft.ID)
|
|
} else {
|
|
attrs.HelicopterID = idString(reserveRow.AircraftID)
|
|
}
|
|
ctx := context.Background()
|
|
if c != nil {
|
|
ctx = c.UserContext()
|
|
}
|
|
attrs.Helicopter = h.takeoverHelicopterResource(ctx, reserveRow)
|
|
}
|
|
if inspectionSummary != nil {
|
|
attrs.Inspection = *inspectionSummary
|
|
}
|
|
ctx := context.Background()
|
|
if c != nil {
|
|
ctx = c.UserContext()
|
|
}
|
|
files := takeoverRow.Files
|
|
if len(files) == 0 && h != nil && h.db != nil {
|
|
if loaded, err := loadTakeoverFilesByTakeoverID(ctx, h.db, takeoverRow.ID); err == nil && len(loaded) > 0 {
|
|
files = loaded
|
|
}
|
|
}
|
|
if len(files) == 0 && h != nil && h.db != nil {
|
|
if loaded, err := loadTakeoverFilesByAttachmentRef(ctx, h.db, takeoverRow.ID); err == nil && len(loaded) > 0 {
|
|
files = takeoverFilesFromAttachments(loaded)
|
|
}
|
|
}
|
|
attrs.Files = takeoverFileResources(ctx, h.storage, files)
|
|
return attrs
|
|
}
|
|
|
|
func loadTakeoverFilesByTakeoverID(ctx context.Context, db *gorm.DB, takeoverID []byte) ([]takeover.TakeoverFile, error) {
|
|
rows := make([]takeover.TakeoverFile, 0)
|
|
if db == nil || len(takeoverID) != 16 {
|
|
return rows, nil
|
|
}
|
|
if err := db.WithContext(ctx).
|
|
Where("takeover_id = ?", takeoverID).
|
|
Order("id ASC").
|
|
Preload("FileAttachment").
|
|
Preload("FileAttachment.File").
|
|
Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func loadTakeoverFilesByAttachmentRef(ctx context.Context, db *gorm.DB, takeoverID []byte) ([]filemanager.Attachment, error) {
|
|
rows := make([]filemanager.Attachment, 0)
|
|
if db == nil || len(takeoverID) != 16 {
|
|
return rows, nil
|
|
}
|
|
if err := db.WithContext(ctx).
|
|
Where("ref_type = ? AND ref_id = ?", "takeover", idString(takeoverID)).
|
|
Order("sort_order ASC, created_at ASC, id ASC").
|
|
Preload("File").
|
|
Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func takeoverFilesFromAttachments(attachments []filemanager.Attachment) []takeover.TakeoverFile {
|
|
if len(attachments) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]takeover.TakeoverFile, 0, len(attachments))
|
|
for i := range attachments {
|
|
att := attachments[i]
|
|
clone := att
|
|
out = append(out, takeover.TakeoverFile{
|
|
ID: append([]byte(nil), att.ID...),
|
|
FileAttachmentID: append([]byte(nil), att.ID...),
|
|
FileAttachment: &clone,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func takeoverFileResources(ctx context.Context, storage fileManagerDownloadURLStorage, files []takeover.TakeoverFile) []responsedto.TakeoverFileResource {
|
|
if len(files) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]responsedto.TakeoverFileResource, 0, len(files))
|
|
for i := range files {
|
|
// Documents created from a template (via /files/create-from-template) are attached
|
|
// to the takeover for WOPI editing, but must not surface in the takeover files
|
|
// listing — only regular attached files are shown there.
|
|
if takeoverFileCreatedFromTemplate(&files[i]) {
|
|
continue
|
|
}
|
|
item := responsedto.TakeoverFileResource{
|
|
Type: "takeover_file",
|
|
ID: idString(files[i].ID),
|
|
Attributes: responsedto.TakeoverFileAttributes{
|
|
FileAttachmentID: idString(files[i].FileAttachmentID),
|
|
},
|
|
}
|
|
if files[i].FileAttachment != nil && files[i].FileAttachment.File != nil {
|
|
file := files[i].FileAttachment.File
|
|
item.Attributes.FileName = strings.TrimSpace(file.Name)
|
|
item.Attributes.DownloadURL = resolveFileManagerDownloadURL(ctx, storage, file)
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// takeoverFileCreatedFromTemplate reports whether a takeover file points to a document
|
|
// instantiated from a template (identified by a non-empty template_uuid on the underlying
|
|
// file). Such files stay attached in the database for WOPI editing but are hidden from the
|
|
// takeover files listing.
|
|
func takeoverFileCreatedFromTemplate(row *takeover.TakeoverFile) bool {
|
|
if row == nil || row.FileAttachment == nil || row.FileAttachment.File == nil {
|
|
return false
|
|
}
|
|
return len(row.FileAttachment.File.TemplateUUID) == 16
|
|
}
|
|
|
|
func (h *TakeoverHandler) buildCreateInspectionSummary(c *fiber.Ctx, inspectionID []byte, helicopterID []byte) (*shareddto.ReserveAcInspectionSummary, error) {
|
|
if h.inspect == nil || h.fileCk == nil || h.heliFile == nil || h.before == nil || h.prep == nil {
|
|
return nil, errors.New("inspection services are not configured")
|
|
}
|
|
inspection, err := h.inspect.GetByID(c.UserContext(), inspectionID)
|
|
if err != nil || inspection == nil {
|
|
return nil, err
|
|
}
|
|
ckRows, err := h.fileCk.ListByInspectionID(c.UserContext(), inspectionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
isDoneByHeliFileID := make(map[string]bool, len(ckRows))
|
|
for i := range ckRows {
|
|
isDoneByHeliFileID[idString(ckRows[i].HelicopterFileID)] = ckRows[i].IsDone
|
|
}
|
|
beforeRows, err := h.heliFile.ListByHelicopterAndSection(c.UserContext(), helicopterID, helicopterfile.SectionBeforeFirstFlightInspection)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
prepareRows, err := h.heliFile.ListByHelicopterAndSection(c.UserContext(), helicopterID, helicopterfile.SectionFlightPreparationAndWB)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
beforeData, _ := h.before.GetByFlightInspectionID(c.UserContext(), inspectionID)
|
|
prepareData, _ := h.prep.GetByFlightInspection(c.UserContext(), inspectionID)
|
|
beforeSection := h.buildChecklistSectionSummary(c, beforeRows, isDoneByHeliFileID)
|
|
prepareSection := h.buildChecklistSectionSummary(c, prepareRows, isDoneByHeliFileID)
|
|
fleetStatusFileSection := h.buildFleetStatusFileSection(c, helicopterID)
|
|
out := &shareddto.ReserveAcInspectionSummary{
|
|
ID: idString(inspection.ID),
|
|
InspectionDate: calendarDateString(inspection.InspectionDate),
|
|
Before: shareddto.ReserveAcBeforeSectionDTO{
|
|
Total: beforeSection.Total,
|
|
Completed: beforeSection.Completed,
|
|
DoneFiles: beforeSection.DoneFiles,
|
|
FileChecklist: beforeSection.FileChecklist,
|
|
},
|
|
Prepare: shareddto.ReserveAcPrepareSectionDTO{
|
|
Total: prepareSection.Total,
|
|
Completed: prepareSection.Completed,
|
|
DoneFiles: prepareSection.DoneFiles,
|
|
FileChecklist: prepareSection.FileChecklist,
|
|
},
|
|
FleetStatusFile: fleetStatusFileSection,
|
|
}
|
|
if beforeData != nil {
|
|
out.Before.Fields = &shareddto.ReserveAcBeforeInspectionFields{
|
|
OilEngineNR1Checked: beforeData.OilEngineNR1Checked,
|
|
OilEngineNR2Checked: beforeData.OilEngineNR2Checked,
|
|
HydraulicLHChecked: beforeData.HydraulicLHChecked,
|
|
HydraulicRHChecked: beforeData.HydraulicRHChecked,
|
|
OilTransmissionMGBChecked: beforeData.OilTransmissionMGBChecked,
|
|
OilTransmissionIGBChecked: beforeData.OilTransmissionIGBChecked,
|
|
OilTransmissionTGBChecked: beforeData.OilTransmissionTGBChecked,
|
|
FuelAmount: beforeData.FuelAmount,
|
|
FuelUnit: strings.TrimSpace(beforeData.FuelUnit),
|
|
FuelAddedAmount: beforeData.FuelAddedAmount,
|
|
}
|
|
}
|
|
if prepareData != nil {
|
|
out.Prepare.PrepareFields = &shareddto.ReserveAcPrepareFields{
|
|
NotamBriefing: boolPtr(prepareData.NOTAMBriefing),
|
|
WeatherBriefing: boolPtr(prepareData.WeatherBriefing),
|
|
OperationalFlightPlan: boolPtr(prepareData.OperationalFlightPlan),
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (h *TakeoverHandler) buildFleetStatusFileSection(c *fiber.Ctx, helicopterID []byte) shareddto.ReserveAcFleetStatusFileSectionDTO {
|
|
if h == nil || h.fleetStatus == nil || len(helicopterID) != 16 {
|
|
return shareddto.ReserveAcFleetStatusFileSectionDTO{}
|
|
}
|
|
latest, err := h.fleetStatus.LatestByHelicopterIDs(c.UserContext(), [][]byte{helicopterID})
|
|
if err != nil || latest == nil {
|
|
return shareddto.ReserveAcFleetStatusFileSectionDTO{}
|
|
}
|
|
row := latest[string(helicopterID)]
|
|
if row == nil || len(row.Files) == 0 {
|
|
return shareddto.ReserveAcFleetStatusFileSectionDTO{}
|
|
}
|
|
out := shareddto.ReserveAcFleetStatusFileSectionDTO{
|
|
Total: len(row.Files),
|
|
Completed: len(row.Files),
|
|
FileChecklist: make([]shareddto.ReserveAcFileChecklistItemDTO, 0, len(row.Files)),
|
|
DoneFiles: make([]shareddto.ReserveAcFileChecklistItemDTO, 0, len(row.Files)),
|
|
}
|
|
for i := range row.Files {
|
|
item := shareddto.ReserveAcFileChecklistItemDTO{
|
|
HelicopterFileID: idString(row.Files[i].ID),
|
|
FileAttachmentID: idString(row.Files[i].FileAttachmentID),
|
|
IsDone: true,
|
|
IsMandatory: row.Files[i].IsMandatory,
|
|
}
|
|
if row.Files[i].Attachment != nil && row.Files[i].Attachment.File != nil {
|
|
file := row.Files[i].Attachment.File
|
|
item.FileName = strings.TrimSpace(file.Name)
|
|
if h.storage != nil {
|
|
asset := resolveFileManagerDownloadURLs(c.UserContext(), h.storage, file)
|
|
item.DownloadURL = strings.TrimSpace(asset.OriginalURL)
|
|
}
|
|
}
|
|
out.FileChecklist = append(out.FileChecklist, item)
|
|
out.DoneFiles = append(out.DoneFiles, item)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func flightTakeoverStep(ctx context.Context, row *flight.Flight, takeoverSvc takeoverLookupService) responsedto.FlightStepItem {
|
|
if row == nil || len(row.TakeoverAcID) != 16 {
|
|
return responsedto.FlightStepItem{
|
|
Phase: "takeover",
|
|
Position: "takeover",
|
|
Step: 1,
|
|
Complete: false,
|
|
Status: "unassigned",
|
|
}
|
|
}
|
|
takeoverRow := resolveTakeoverRow(ctx, row, takeoverSvc)
|
|
if !takeoverRowIsComplete(takeoverRow) {
|
|
return responsedto.FlightStepItem{
|
|
Phase: "takeover",
|
|
Position: "takeover",
|
|
Step: 1,
|
|
Complete: false,
|
|
Status: "in_progress",
|
|
ID: idString(row.TakeoverAcID),
|
|
}
|
|
}
|
|
return responsedto.FlightStepItem{
|
|
Phase: "takeover",
|
|
Position: "takeover",
|
|
Step: 1,
|
|
Complete: true,
|
|
Status: "completed",
|
|
ID: idString(row.TakeoverAcID),
|
|
CompletedAt: row.CreatedAt.UTC().Format(time.RFC3339),
|
|
CompletedBy: takeoverCompletedBy(takeoverRow),
|
|
}
|
|
}
|
|
|
|
func takeoverCompletedBy(takeoverRow *takeover.TakeoverAc) string {
|
|
if takeoverRow == nil {
|
|
return ""
|
|
}
|
|
if short := strings.TrimSpace(userctx.GetShortName(context.Background(), takeoverRow.UpdatedBy)); short != "" {
|
|
return short
|
|
}
|
|
if short := strings.TrimSpace(userctx.GetShortName(context.Background(), takeoverRow.CreatedBy)); short != "" {
|
|
return short
|
|
}
|
|
for i := range takeoverRow.RosterCrews {
|
|
crew := takeoverRow.RosterCrews[i]
|
|
if !strings.EqualFold(strings.TrimSpace(crew.RoleCode), "pilot") {
|
|
continue
|
|
}
|
|
if short := strings.TrimSpace(userctx.GetShortName(context.Background(), crew.UserID)); short != "" {
|
|
return short
|
|
}
|
|
if label := strings.TrimSpace(crew.NameLabel); label != "" {
|
|
return label
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// resolveTakeoverRow loads the takeover record for a flight, preferring the
|
|
// lookup service and falling back to the row's eagerly-loaded association.
|
|
// Returns nil when the id is missing or the lookup errors.
|
|
func resolveTakeoverRow(ctx context.Context, row *flight.Flight, takeoverSvc takeoverLookupService) *takeover.TakeoverAc {
|
|
if row == nil || len(row.TakeoverAcID) != 16 {
|
|
return nil
|
|
}
|
|
var takeoverRow *takeover.TakeoverAc
|
|
if takeoverSvc != nil {
|
|
var err error
|
|
takeoverRow, err = takeoverSvc.GetByID(ctx, row.TakeoverAcID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
}
|
|
if takeoverRow == nil {
|
|
takeoverRow = row.Takeover
|
|
}
|
|
return takeoverRow
|
|
}
|
|
|
|
func takeoverRowIsComplete(takeoverRow *takeover.TakeoverAc) bool {
|
|
if takeoverRow == nil {
|
|
return false
|
|
}
|
|
if len(takeoverRow.BaseID) != 16 {
|
|
return false
|
|
}
|
|
if len(takeoverRow.RosterCrews) == 0 && len(takeoverRow.OtherPeople) == 0 {
|
|
return false
|
|
}
|
|
if takeoverRow.ReserveAc == nil {
|
|
return false
|
|
}
|
|
if len(takeoverRow.ReserveAc.AircraftID) != 16 || takeoverRow.ReserveAc.Aircraft == nil {
|
|
return false
|
|
}
|
|
if takeoverRow.ReserveAc.Inspection == nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func takeoverDutyRosterResponse(resource *responsedto.DutyRosterResource, flightRow *flight.Flight) *responsedto.TakeoverDutyRosterResource {
|
|
if resource == nil {
|
|
return nil
|
|
}
|
|
helicopter := resource.Attributes.Helicopter
|
|
if helicopter.ID == "" && helicopter.Designation == "" && helicopter.Identifier == "" && helicopter.Type == "" {
|
|
if fallback := takeoverHelicopterFromFlight(flightRow); fallback != nil {
|
|
helicopter = *fallback
|
|
}
|
|
}
|
|
return &responsedto.TakeoverDutyRosterResource{
|
|
Type: resource.Type,
|
|
ID: resource.ID,
|
|
Attributes: responsedto.TakeoverDutyRosterAttributes{
|
|
Bases: resource.Attributes.Bases,
|
|
FlightID: resource.Attributes.FlightID,
|
|
Helicopter: helicopter,
|
|
DutyDate: resource.Attributes.DutyDate,
|
|
Detail: takeoverDutyRosterDetailsFromRosterDetails(resource.Attributes.Detail),
|
|
CreatedBy: resource.Attributes.CreatedBy,
|
|
CreatedAt: resource.Attributes.CreatedAt,
|
|
UpdatedAt: resource.Attributes.UpdatedAt,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (h *TakeoverHandler) takeoverBaseInfo(c *fiber.Ctx, resource *responsedto.DutyRosterResource, baseID []byte) *responsedto.DutyRosterBaseInfo {
|
|
if resource != nil {
|
|
base := resource.Attributes.Bases
|
|
if base.ID != "" || base.Type != "" || base.BaseName != "" || base.BaseAbbreviation != "" {
|
|
if base.ShiftTime == (responsedto.DutyRosterShiftTime{}) {
|
|
if shift := h.resolveTakeoverShiftTime(c, baseID, parseDutyRosterDate(resource.Attributes.DutyDate)); shift != (responsedto.DutyRosterShiftTime{}) {
|
|
base.ShiftTime = shift
|
|
}
|
|
}
|
|
if base.ShiftTime == (responsedto.DutyRosterShiftTime{}) {
|
|
base.ShiftTime = resource.Attributes.ShiftTime
|
|
}
|
|
return &base
|
|
}
|
|
}
|
|
if len(baseID) == 0 {
|
|
return nil
|
|
}
|
|
out := &responsedto.DutyRosterBaseInfo{ID: idString(baseID)}
|
|
if row := h.takeoverBaseRow(c, baseID, "hems", "regular"); row != nil {
|
|
out.Type = takeoverBaseType(row)
|
|
out.BaseName = strings.TrimSpace(row.BaseName)
|
|
out.BaseAbbreviation = strings.TrimSpace(row.BaseAbbreviation)
|
|
}
|
|
if resource != nil {
|
|
out.ShiftTime = h.resolveTakeoverShiftTime(c, baseID, parseDutyRosterDate(resource.Attributes.DutyDate))
|
|
if out.ShiftTime == (responsedto.DutyRosterShiftTime{}) {
|
|
out.ShiftTime = resource.Attributes.ShiftTime
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *TakeoverHandler) takeoverBaseRow(c *fiber.Ctx, baseID []byte, categories ...string) *basedomain.Base {
|
|
if h == nil || h.base == nil || len(baseID) != 16 {
|
|
return nil
|
|
}
|
|
ctx := context.Background()
|
|
if c != nil {
|
|
ctx = c.UserContext()
|
|
}
|
|
tried := map[string]struct{}{}
|
|
for _, category := range categories {
|
|
category = strings.TrimSpace(category)
|
|
if category == "" {
|
|
continue
|
|
}
|
|
if _, ok := tried[category]; ok {
|
|
continue
|
|
}
|
|
tried[category] = struct{}{}
|
|
row, err := h.base.GetBaseByID(ctx, baseID, category)
|
|
if err == nil && row != nil {
|
|
return row
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func takeoverBaseType(row *basedomain.Base) string {
|
|
if row == nil {
|
|
return ""
|
|
}
|
|
if normalized, ok := basedomain.NormalizeCategoryType(row.BaseCategory.Key); ok {
|
|
return normalized
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *TakeoverHandler) resolveTakeoverShiftTime(c *fiber.Ctx, baseID []byte, dutyDate time.Time) responsedto.DutyRosterShiftTime {
|
|
if h == nil || h.db == nil || len(baseID) != 16 || dutyDate.IsZero() {
|
|
return responsedto.DutyRosterShiftTime{}
|
|
}
|
|
ctx := context.Background()
|
|
if c != nil {
|
|
ctx = c.UserContext()
|
|
}
|
|
shiftStart, shiftEnd, err := service.ResolveBaseShiftRangeTx(h.db.WithContext(ctx), baseID, dutyDate)
|
|
if err != nil || shiftStart == "" || shiftEnd == "" {
|
|
return responsedto.DutyRosterShiftTime{}
|
|
}
|
|
return responsedto.DutyRosterShiftTime{ShiftStart: shiftStart, ShiftEnd: shiftEnd}
|
|
}
|
|
|
|
func parseDutyRosterDate(value string) time.Time {
|
|
parsed, err := time.Parse("2006-01-02", strings.TrimSpace(value))
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func calendarDateString(value time.Time) string {
|
|
if value.IsZero() {
|
|
return ""
|
|
}
|
|
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC).Format("2006-01-02")
|
|
}
|
|
|
|
func takeoverHelicopterInfo(resource *responsedto.DutyRosterResource, helicopterID []byte) *responsedto.DutyRosterHelicopterSummary {
|
|
if resource != nil {
|
|
heli := resource.Attributes.Helicopter
|
|
if heli.ID != "" || heli.Designation != "" || heli.Identifier != "" || heli.Type != "" {
|
|
return &heli
|
|
}
|
|
}
|
|
if len(helicopterID) == 0 {
|
|
return nil
|
|
}
|
|
return &responsedto.DutyRosterHelicopterSummary{ID: idString(helicopterID)}
|
|
}
|
|
|
|
// takeoverHelicopterResource builds the selected helicopter for a takeover,
|
|
// enriched with its last daily inspection and its maintenance schedule (the
|
|
// "next due" per inspection type) from the helicopter's latest fleet status.
|
|
func (h *TakeoverHandler) takeoverHelicopterResource(ctx context.Context, row *reserveac.ReserveAc) *responsedto.TakeoverHelicopter {
|
|
if row == nil {
|
|
return nil
|
|
}
|
|
helicopterID := row.AircraftID
|
|
heli := responsedto.TakeoverHelicopter{ID: idString(row.AircraftID)}
|
|
if row.Aircraft != nil {
|
|
helicopterID = row.Aircraft.ID
|
|
heli.ID = idString(row.Aircraft.ID)
|
|
heli.Designation = strings.TrimSpace(row.Aircraft.Designation)
|
|
heli.Identifier = strings.TrimSpace(row.Aircraft.Identifier)
|
|
heli.Type = strings.TrimSpace(row.Aircraft.Type)
|
|
}
|
|
if heli.ID == "" && heli.Designation == "" && heli.Identifier == "" && heli.Type == "" {
|
|
return nil
|
|
}
|
|
// Last daily inspection: reserveAc concretely implements the provider.
|
|
if provider, ok := h.reserveAc.(helicopterLastDailyInspectionProvider); ok {
|
|
heli.LastDailyInspection = helicopterLastDailyInspectionResource(ctx, provider, helicopterID)
|
|
}
|
|
heli.MaintenanceSchedules = h.takeoverMaintenanceSchedules(ctx, helicopterID)
|
|
return &heli
|
|
}
|
|
|
|
// takeoverMaintenanceSchedules returns the full inspection-type set (with due/ext)
|
|
// from the helicopter's latest fleet status; empty entries when none exists.
|
|
func (h *TakeoverHandler) takeoverMaintenanceSchedules(ctx context.Context, helicopterID []byte) []shareddto.MaintenanceScheduleAttributes {
|
|
var schedules []fleetstatus.MaintenanceSchedule
|
|
if h.fleetStatus != nil && len(helicopterID) == 16 {
|
|
if latest, err := h.fleetStatus.LatestByHelicopterIDs(ctx, [][]byte{helicopterID}); err == nil {
|
|
if fs := latest[string(helicopterID)]; fs != nil {
|
|
schedules = fs.MaintenanceSchedules
|
|
}
|
|
}
|
|
}
|
|
return fleetStatusMaintenanceSchedules(schedules)
|
|
}
|
|
|
|
func takeoverHelicopterFromFlight(row *flight.Flight) *responsedto.DutyRosterHelicopterSummary {
|
|
return nil
|
|
}
|
|
|
|
func (h *TakeoverHandler) buildChecklistSectionSummary(c *fiber.Ctx, files []helicopterfile.HelicopterFile, isDoneByHeliFileID map[string]bool) reserveAcChecklistSectionCommon {
|
|
out := reserveAcChecklistSectionCommon{
|
|
FileChecklist: make([]shareddto.ReserveAcFileChecklistItemDTO, 0, len(files)),
|
|
DoneFiles: make([]shareddto.ReserveAcFileChecklistItemDTO, 0, len(files)),
|
|
}
|
|
for i := range files {
|
|
if takeoverChecklistFileCreatedFromTemplate(&files[i]) {
|
|
continue
|
|
}
|
|
fileID := idString(files[i].ID)
|
|
isDone := isDoneByHeliFileID[fileID]
|
|
if isDone {
|
|
out.Completed++
|
|
}
|
|
out.Total++
|
|
fileAttachmentID := ""
|
|
fileName := ""
|
|
if files[i].FileAttachment != nil {
|
|
fileAttachmentID = idString(files[i].FileAttachment.ID)
|
|
if files[i].FileAttachment.File != nil {
|
|
fileName = strings.TrimSpace(files[i].FileAttachment.File.Name)
|
|
}
|
|
}
|
|
downloadURL := ""
|
|
if files[i].FileAttachment != nil && files[i].FileAttachment.File != nil {
|
|
downloadURL = resolveFileManagerDownloadURL(c.UserContext(), h.storage, files[i].FileAttachment.File)
|
|
}
|
|
item := shareddto.ReserveAcFileChecklistItemDTO{
|
|
HelicopterFileID: fileID,
|
|
FileAttachmentID: fileAttachmentID,
|
|
FileName: fileName,
|
|
DownloadURL: downloadURL,
|
|
IsDone: isDone,
|
|
}
|
|
out.FileChecklist = append(out.FileChecklist, item)
|
|
if item.IsDone {
|
|
out.DoneFiles = append(out.DoneFiles, item)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// takeoverChecklistFileCreatedFromTemplate reports whether a helicopter file row is a
|
|
// document instantiated from a template. Those rows should remain hidden from checklist
|
|
// summaries, which only surface the template catalog entries themselves.
|
|
func takeoverChecklistFileCreatedFromTemplate(row *helicopterfile.HelicopterFile) bool {
|
|
if row == nil {
|
|
return false
|
|
}
|
|
if row.SourceFile != nil {
|
|
return len(row.SourceFile.TemplateUUID) == 16
|
|
}
|
|
if row.FileAttachment != nil && row.FileAttachment.File != nil {
|
|
return len(row.FileAttachment.File.TemplateUUID) == 16
|
|
}
|
|
return false
|
|
}
|