Files
fm_be/internal/transport/http/handlers/takeover_handler.go
2026-07-16 22:16:45 +07:00

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
}