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 }