package handlers import ( "context" "errors" "regexp" "strconv" "strings" "time" mysqldriver "github.com/go-sql-driver/mysql" "github.com/gofiber/fiber/v2" "gorm.io/gorm" dutyroster "wucher/internal/domain/duty_roster" "wucher/internal/domain/flight" "wucher/internal/service" "wucher/internal/shared/pkg/apperrorsx" "wucher/internal/shared/pkg/uuidv7" dto "wucher/internal/transport/http/dto" requestdto "wucher/internal/transport/http/dto/request" responsedto "wucher/internal/transport/http/dto/response" "wucher/internal/transport/http/jsonapi" "wucher/internal/transport/http/response" "wucher/internal/transport/http/validators" ) type DutyRosterHandler struct { svc dutyroster.Service flight flight.FlightService validate *validators.Validator audit *service.AuditLogService } var _ responsedto.DutyRosterResponse var _ dto.ErrorResponse var _ dto.GenericDeleteResponse type rosterFlightMeta struct { FlightID string Helicopter responsedto.DutyRosterHelicopterSummary } func toCoreDutyRosterDetailsInput(in requestdto.DutyRosterDetailsInput) requestdto.DutyRosterDetailsInput { 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 i := range in.Pilot { out.Pilot = append(out.Pilot, requestdto.DutyRosterPilotCrewInput{ ID: in.Pilot[i].ID, ShiftDate: toCoreShiftDatePtr(in.Pilot[i].ShiftDate), FlightInstructor: in.Pilot[i].FlightInstructor, LineChecker: in.Pilot[i].LineChecker, Supervisor: in.Pilot[i].Supervisor, Examiner: in.Pilot[i].Examiner, CoPilot: in.Pilot[i].CoPilot, CrewType: in.Pilot[i].CrewType, }) } for i := range in.Doctor { out.Doctor = append(out.Doctor, toCoreCrewInput(in.Doctor[i])) } for i := range in.Rescuer { out.Rescuer = append(out.Rescuer, toCoreCrewInput(in.Rescuer[i])) } for i := range in.Helper { out.Helper = append(out.Helper, toCoreCrewInput(in.Helper[i])) } for i := range in.OtherPerson { out.OtherPerson = append(out.OtherPerson, requestdto.DutyRosterOtherPersonInput{ Name: in.OtherPerson[i].Name, MobilePhone: in.OtherPerson[i].MobilePhone, Email: in.OtherPerson[i].Email, }) } return out } func toCoreDutyRosterDetailsInputPtr(in *requestdto.DutyRosterDetailsInput) *requestdto.DutyRosterDetailsInput { if in == nil { return nil } out := toCoreDutyRosterDetailsInput(*in) return &out } func toCoreCrewInput(in requestdto.DutyRosterCrewInput) requestdto.DutyRosterCrewInput { return requestdto.DutyRosterCrewInput{ ID: in.ID, ShiftDate: toCoreShiftDatePtr(in.ShiftDate), } } func toCoreShiftDatePtr(in *requestdto.DutyRosterShiftDate) *requestdto.DutyRosterShiftDate { if in == nil { return nil } return &requestdto.DutyRosterShiftDate{ DateStart: in.DateStart, DateEnd: in.DateEnd, } } func NewDutyRosterHandler(svc dutyroster.Service) *DutyRosterHandler { return &DutyRosterHandler{svc: svc, validate: validators.New()} } func (h *DutyRosterHandler) WithAudit(auditSvc *service.AuditLogService) *DutyRosterHandler { h.audit = auditSvc return h } func (h *DutyRosterHandler) WithFlightService(flightSvc flight.FlightService) *DutyRosterHandler { h.flight = flightSvc return h } // CreateDutyRoster godoc // @Summary Create HEMS duty roster // @Description Create roster by `hems_base_id` and `duty_date`. // @Description HEMS endpoints use `hems_base_id` in request and `hems_bases` in response attributes. // @Description Time input accepts `HH:MM` or `HH:MM:SS` and is normalized. // @Description `roster_detail` buckets: `pilot`, `doctor`, `rescuer`, `helper`, `other_person`. // @Description For pilot: `crew_type` can be `main` or `additional`. // @Description Pilot flags (`flight_instructor`, `line_checker`, `supervisor`, `examiner`, `co_pilot`) are only applied when `crew_type=additional`; for `main` they are ignored. // @Description For doctor/rescuer/helper, `crew_type` is always treated as `main`. // @Description For `other_person`, `id` must be empty and `name` is required. // @Description If roster shift_time is omitted, system uses base default shift when available. // @Description `data.type` should be `duty_roster_create` (or `duty_roster`). Legacy `hems_duty_roster_create`/`hems_duty_roster` is still accepted for backward compatibility. // @Tags Flights - HEMS Duty Roster // @Accept json // @Produce json // @Param request body requestdto.DutyRosterCreateRequest true "Create request payload" // @Success 201 {object} responsedto.DutyRosterResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/hems-roster/create func (h *DutyRosterHandler) Create(c *fiber.Ctx) error { var req requestdto.DutyRosterCreateRequest if err := c.BodyParser(&req); err != nil { return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, errs) } baseID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.BaseID)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"}, }}) } dutyDate, err := service.ParseDutyRosterDateOnly(req.Data.Attributes.DutyDate) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "duty_date must be YYYY-MM-DD", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/duty_date"}, }}) } row := &dutyroster.DutyRoster{ BaseID: baseID, DutyDate: dutyDate, CreatedBy: actorUserID(c), UpdatedBy: actorUserID(c), } defaultShiftStart, defaultShiftEnd, err := h.svc.ResolveBaseDefaultShiftRange(c.UserContext(), baseID, "hems") if err != nil { if wrote := h.writeBaseTypeValidationError(c, err, baseID, "hems", "/data/attributes/base_id"); wrote { return nil } status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } rowShiftStart, shiftErr := service.NormalizeDutyRosterClockInput(req.Data.Attributes.ShiftTime.ShiftStart, defaultShiftStart, "/data/attributes/shift_time/shift_start") if shiftErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: shiftErr.Detail, Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer}, }}) } rowShiftEnd, shiftErr := service.NormalizeDutyRosterClockInput(req.Data.Attributes.ShiftTime.ShiftEnd, defaultShiftEnd, "/data/attributes/shift_time/shift_end") if shiftErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: shiftErr.Detail, Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer}, }}) } _ = rowShiftStart _ = rowShiftEnd detail := toCoreDutyRosterDetailsInput(req.Data.Attributes.Detail) crews, crewErr := service.BuildDutyRosterCrewRows(detail, actorUserID(c)) if crewErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: crewErr.Detail, Source: &jsonapi.ErrorSource{Pointer: crewErr.Pointer}, }}) } clearCrewShiftDateForCreate(crews) if err := h.svc.Create(c.UserContext(), row, crews); err != nil { status, errObj := buildServiceErrorObject("Create failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } return h.respondByID(c, fiber.StatusCreated, row.ID) } // GetDutyRoster godoc // @Summary Get HEMS duty roster by UUID // @Tags Flights - HEMS Duty Roster // @Produce json // @Param uuid path string true "Roster UUID" // @Success 200 {object} responsedto.DutyRosterResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/hems-roster/get/{uuid} func (h *DutyRosterHandler) Get(c *fiber.Ctx) error { id, err := uuidv7.ParseString(dutyRosterPathID(c)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) } header, err := h.svc.GetHeaderByID(c.UserContext(), id) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if header != nil { return h.respondByID(c, fiber.StatusOK, id) } // Backward-compatible fallback: allow :uuid as flight_uuid. if h.flight != nil { flightRow, flightErr := h.flight.GetByID(c.UserContext(), id) if flightErr != nil { status, errObj := buildServiceErrorObject("Get failed", flightErr, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if flightRow != nil && len(flightRow.DutyRosterID) == 16 { return h.respondByID(c, fiber.StatusOK, flightRow.DutyRosterID) } } return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "hems duty roster not found", }}) } func (h *DutyRosterHandler) GetByAuthUser(c *fiber.Ctx) error { userID := actorUserID(c) if len(userID) == 0 { return writeDutyRosterErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } rosterID, err := h.findLatestRosterIDByUser(c.UserContext(), userID, "hems") if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if len(rosterID) != 16 { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "hems duty roster not found", }}) } return h.respondByID(c, fiber.StatusOK, rosterID) } // GetRosterByAuthUser godoc // @Summary Get latest roster for current auth user (HEMS/Base) // @Description Resolve latest roster linked to the authenticated user from flight assignments. // @Tags Flights - Duty Roster // @Produce json // @Success 200 {object} jsonapi.Document // @Failure 401 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 500 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/duty-roster/get func (h *DutyRosterHandler) GetRosterByAuthUser(c *fiber.Ctx) error { userID := actorUserID(c) if len(userID) == 0 { return writeDutyRosterErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } if h.flight == nil { return writeDutyRosterErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{ Status: "500", Title: "Get failed", Detail: "flight service is not configured", }}) } rows, _, err := h.flight.ListByUserID(c.UserContext(), userID, 100, 0) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } rosterIDs := make([][]byte, 0, len(rows)) seenRoster := make(map[string]struct{}, len(rows)) flightByRoster := make(map[string]rosterFlightMeta, len(rows)) for i := range rows { if len(rows[i].DutyRosterID) != 16 { continue } rosterKey := idString(rows[i].DutyRosterID) if rosterKey == "" { continue } if _, seen := seenRoster[rosterKey]; !seen { seenRoster[rosterKey] = struct{}{} rosterIDs = append(rosterIDs, append([]byte(nil), rows[i].DutyRosterID...)) } next := rosterFlightMeta{ FlightID: idString(rows[i].ID), Helicopter: buildRosterHelicopter(rows[i]), } prev, exists := flightByRoster[rosterKey] if !exists || shouldReplaceRosterFlightMeta(prev, next) { flightByRoster[rosterKey] = next } } if len(rosterIDs) == 0 { return response.WriteWithMeta(c, fiber.StatusOK, []any{}, map[string]any{"total": 0}) } crews, err := h.svc.GetCrewsByRosterIDs(c.UserContext(), rosterIDs) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } crewsByRoster := map[string][]dutyroster.CrewView{} for i := range crews { k := idString(crews[i].RosterID) if k == "" { continue } crewsByRoster[k] = append(crewsByRoster[k], crews[i]) } data := make([]any, 0, len(rosterIDs)) for i := range rosterIDs { rosterID := rosterIDs[i] rosterKey := idString(rosterID) rosterCrews := crewsByRoster[rosterKey] headerHEMS, getErr := h.svc.GetHeaderByID(c.UserContext(), rosterID) if getErr != nil { status, errObj := buildServiceErrorObject("Get failed", getErr, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if headerHEMS != nil { resource := dutyRosterResource(c.UserContext(), *headerHEMS, rosterCrews, "hems") if flightMeta, ok := flightByRoster[rosterKey]; ok { if strings.TrimSpace(flightMeta.FlightID) != "" { resource.Attributes.FlightID = strings.TrimSpace(flightMeta.FlightID) } if !isEmptyRosterHelicopter(flightMeta.Helicopter) { resource.Attributes.Helicopter = flightMeta.Helicopter } } data = append(data, resource) continue } headerBase, getErr := h.svc.GetHeaderByIDByBaseType(c.UserContext(), rosterID, "base") if getErr != nil { status, errObj := buildServiceErrorObject("Get failed", getErr, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if headerBase != nil { resource := dutyRosterResource(c.UserContext(), *headerBase, rosterCrews, "base") if flightMeta, ok := flightByRoster[rosterKey]; ok { if strings.TrimSpace(flightMeta.FlightID) != "" { resource.Attributes.FlightID = strings.TrimSpace(flightMeta.FlightID) } if !isEmptyRosterHelicopter(flightMeta.Helicopter) { resource.Attributes.Helicopter = flightMeta.Helicopter } } data = append(data, resource) } } return response.WriteWithMeta(c, fiber.StatusOK, data, map[string]any{"total": len(data)}) } // GetAllRosters godoc // @Summary List all rosters (HEMS + Base) // @Description Combined roster list across HEMS and Base for date range and optional base filters. // @Tags Flights - Duty Roster // @Produce json // @Param hems_base_id query string false "HEMS base UUID" // @Param base_id query string false "Base UUID" // @Param base_name query string false "Filter by base name (contains, case-insensitive)" // @Param from query string false "Start date (YYYY-MM-DD)" // @Param to query string false "End date (YYYY-MM-DD)" // @Success 200 {object} jsonapi.Document // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/duty-roster/get-all [get] func (h *DutyRosterHandler) GetAllRosters(c *fiber.Ctx) error { hemsBaseRaw := strings.TrimSpace(c.Query("hems_base_id")) baseRaw := strings.TrimSpace(c.Query("base_id")) baseName := strings.TrimSpace(c.Query("base_name")) var hemsBaseID []byte if hemsBaseRaw != "" { parsed, err := uuidv7.ParseString(hemsBaseRaw) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "hems_base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/query/hems_base_id"}, }}) } hemsBaseID = parsed } var baseID []byte if baseRaw != "" { parsed, err := uuidv7.ParseString(baseRaw) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/query/base_id"}, }}) } baseID = parsed } from, to, rangeErr := service.ParseDutyRosterDateRangeOrDefault(c.Query("from"), c.Query("to"), time.Now().UTC()) if rangeErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: rangeErr.Detail, Source: &jsonapi.ErrorSource{Pointer: rangeErr.Pointer}, }}) } if to.Before(from) { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "to must be >= from", Source: &jsonapi.ErrorSource{Pointer: "/query/to"}, }}) } hemsGroups, err := loadRosterGroups(c.UserContext(), h.svc, hemsBaseID, from, to, baseName, "hems") if err != nil { status, errObj := buildServiceErrorObject("List failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } h.attachFlightIDsToHEMSGroups(c.UserContext(), hemsGroups) baseGroups, err := loadRosterGroups(c.UserContext(), h.svc, baseID, from, to, baseName, "base") if err != nil { status, errObj := buildServiceErrorObject("List failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } h.attachFlightIDsToBaseGroups(c.UserContext(), baseGroups) return response.WriteWithMeta(c, fiber.StatusOK, map[string]any{ "hems": hemsGroups, "base": baseGroups, }, map[string]any{ "from": from.UTC().Format("2006-01-02"), "to": to.UTC().Format("2006-01-02"), "timezone": "UTC", "total_hems": len(hemsGroups), "total_base": len(baseGroups), "total": len(hemsGroups) + len(baseGroups), }) } func (h *DutyRosterHandler) GetByFlightID(c *fiber.Ctx) error { flightID, err := uuidv7.ParseString(strings.TrimSpace(dutyRosterPathFlightID(c))) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "flight_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/flight_id"}, }}) } if h.flight == nil { return writeDutyRosterErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{ Status: "500", Title: "Get failed", Detail: "flight service is not configured", }}) } flightRow, err := h.flight.GetByID(c.UserContext(), flightID) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if flightRow == nil { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "flight not found", }}) } if len(flightRow.DutyRosterID) != 16 { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "duty roster not linked to flight", }}) } // Try HEMS format first. header, err := h.svc.GetHeaderByID(c.UserContext(), flightRow.DutyRosterID) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if header != nil { return h.respondByID(c, fiber.StatusOK, flightRow.DutyRosterID) } // Fallback to base format. return h.respondByIDBaseType(c, fiber.StatusOK, flightRow.DutyRosterID, "base") } // UpdateDutyRoster godoc // @Summary Update HEMS duty roster (partial) // @Description Update header fields and/or append/replace crew assignment by role+crew_type+user. // @Description `data.id` is optional; if provided it must match path `uuid`. // @Description If `roster_detail` is omitted, only header is updated. // @Description Crew update is additive by default; same role+crew_type+user in same roster is replaced. // @Description For pilot rows, extra flags (`flight_instructor`, `line_checker`, `supervisor`, `examiner`, `co_pilot`) are only applied for `crew_type=additional`. // @Description `data.type` should be `duty_roster_update` (or `duty_roster`). Legacy `hems_duty_roster_update`/`hems_duty_roster` is still accepted for backward compatibility. // @Tags Flights - HEMS Duty Roster // @Accept json // @Produce json // @Param uuid path string true "Roster UUID" // @Param request body requestdto.DutyRosterUpdateRequest true "Update request payload" // @Success 200 {object} responsedto.DutyRosterResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/hems-roster/update/{uuid} func (h *DutyRosterHandler) Update(c *fiber.Ctx) error { var req requestdto.DutyRosterUpdateRequest if err := c.BodyParser(&req); err != nil { return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, errs) } uuidStr := strings.TrimSpace(dutyRosterPathID(c)) bodyID := strings.TrimSpace(req.Data.ID) if uuidStr == "" && bodyID == "" { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is required", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } if uuidStr != "" && bodyID != "" && bodyID != uuidStr { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id does not match path id", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } targetID := bodyID if targetID == "" { targetID = uuidStr } id, err := h.resolveDutyRosterIDFromInput(c, targetID) if err != nil { sourcePtr := "/data/id" if targetID == uuidStr && uuidStr != "" && bodyID == "" { sourcePtr = "/path/id" } return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: safeInternalDetail(), Source: &jsonapi.ErrorSource{Pointer: sourcePtr}, }}) } existing, err := h.svc.GetHeaderByID(c.UserContext(), id) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if existing == nil { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "hems duty roster not found", }}) } row := &dutyroster.DutyRoster{ID: id, BaseID: append([]byte(nil), existing.BaseID...), DutyDate: existing.DutyDate, UpdatedBy: actorUserID(c)} attrs := req.Data.Attributes if attrs.BaseID != nil && strings.TrimSpace(*attrs.BaseID) != "" { baseID, err := uuidv7.ParseString(strings.TrimSpace(*attrs.BaseID)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "hems_base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/hems_base_id"}, }}) } row.BaseID = baseID } if attrs.DutyDate != nil && strings.TrimSpace(*attrs.DutyDate) != "" { d, err := service.ParseDutyRosterDateOnly(*attrs.DutyDate) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "duty_date must be YYYY-MM-DD", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/duty_date"}, }}) } row.DutyDate = d } if attrs.ShiftTime != nil { defaultShiftStart, defaultShiftEnd, err := service.ResolveDutyRosterShiftDefaultsForUpdate(c.UserContext(), h.svc, row.BaseID, "hems", "", "") if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } shiftStart, shiftErr := service.NormalizeDutyRosterClockInput(attrs.ShiftTime.ShiftStart, defaultShiftStart, "/data/attributes/shift_time/shift_start") if shiftErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: shiftErr.Detail, Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer}, }}) } shiftEnd, shiftErr := service.NormalizeDutyRosterClockInput(attrs.ShiftTime.ShiftEnd, defaultShiftEnd, "/data/attributes/shift_time/shift_end") if shiftErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: shiftErr.Detail, Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer}, }}) } _ = shiftStart _ = shiftEnd } crews := []dutyroster.DutyRosterCrew{} if detail := toCoreDutyRosterDetailsInputPtr(attrs.Detail); detail != nil { built, crewErr := service.BuildDutyRosterCrewRows(*detail, actorUserID(c)) if crewErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: crewErr.Detail, Source: &jsonapi.ErrorSource{Pointer: crewErr.Pointer}, }}) } crews = built } if err := h.svc.Update(c.UserContext(), row, crews); err != nil { status, errObj := buildServiceErrorObject("Update failed", err, "hems duty roster not found") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } h.logCrewAudit(c, "duty_roster.crew.append", id, "hems", crews, "append crew assignments") return h.respondByID(c, fiber.StatusOK, id) } // DeleteDutyRoster godoc // @Summary Delete HEMS duty roster // @Description Delete one roster header and all attached crews by roster UUID. // @Tags Flights - HEMS Duty Roster // @Produce json // @Param uuid path string true "Roster UUID" // @Success 200 {object} dto.GenericDeleteResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/hems-roster/delete/{uuid} func (h *DutyRosterHandler) Delete(c *fiber.Ctx) error { id, err := uuidv7.ParseString(dutyRosterPathID(c)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) } if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil { status, errObj := buildServiceErrorObject("Delete failed", err, "hems duty roster not found") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "duty_roster_delete", Attributes: map[string]any{"deleted": true}}) } // DeleteDutyRosterCrew godoc // @Summary Remove crew assignment from HEMS duty roster // @Description Remove selected crew rows from roster UUID by filter in `roster_detail`. // @Description This endpoint removes crew only; header roster stays. // @Description For operational roles (pilot/doctor/rescuer/helper), if one `shift_date` boundary is sent then both are required. // @Description If `shift_date` is omitted, filter applies to roster day only. // @Description `data.type` should be `duty_roster_crew_delete` (also accepts `duty_roster_update`/`duty_roster` and legacy `hems_*` variants). // @Tags Flights - HEMS Duty Roster // @Accept json // @Produce json // @Param uuid path string true "Roster UUID" // @Param request body requestdto.DutyRosterDeleteCrewRequest true "Crew delete filter payload (use `roster_detail`)" // @Success 200 {object} responsedto.DutyRosterCrewDeleteResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/hems-roster/{uuid}/crew func (h *DutyRosterHandler) DeleteCrew(c *fiber.Ctx) error { id, err := uuidv7.ParseString(dutyRosterPathID(c)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) } crews, parseErr := service.BuildDutyRosterDeleteCrews(c.Body(), actorUserID(c)) if parseErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: parseErr.Detail, Source: &jsonapi.ErrorSource{Pointer: parseErr.Pointer}, }}) } if len(crews) == 0 { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "roster_detail is required", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/roster_detail"}, }}) } deletedRows, err := h.svc.DeleteCrews(c.UserContext(), id, crews, actorUserID(c)) if err != nil { status, errObj := buildServiceErrorObject("Delete failed", err, "hems duty roster not found") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if len(deletedRows) == 0 { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "no crew assignment matched delete filter", }}) } h.logCrewAudit(c, "duty_roster.crew.remove", id, "hems", crews, "remove crew assignments") return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "duty_roster_crew_delete", Attributes: map[string]any{"deleted": true, "deleted_count": len(deletedRows), "deleted_items": dutyRosterDeletedCrewItems(deletedRows)}}) } // ListDutyRoster godoc // @Summary List HEMS duty roster // @Description Grouped by base. Includes empty bases with `rosters: []`. // @Description Query: `hems_base_id` (optional), `base_name` (optional), `from` and `to` (`YYYY-MM-DD`, optional). // @Description Response grouping key is `attributes.hems_bases`. // @Tags Flights - HEMS Duty Roster // @Produce json // @Param hems_base_id query string false "HEMS base UUID" // @Param base_name query string false "Filter by HEMS base name (contains, case-insensitive)" // @Param from query string false "Start date (YYYY-MM-DD)" // @Param to query string false "End date (YYYY-MM-DD)" // @Success 200 {object} responsedto.DutyRosterGroupedListResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/hems-roster/get-all func (h *DutyRosterHandler) List(c *fiber.Ctx) error { hemsBaseRaw := strings.TrimSpace(c.Query("hems_base_id")) var baseID []byte var err error if hemsBaseRaw != "" { baseID, err = uuidv7.ParseString(hemsBaseRaw) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "hems_base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/query/hems_base_id"}, }}) } } from, to, rangeErr := service.ParseDutyRosterDateRangeOrDefault(c.Query("from"), c.Query("to"), time.Now().UTC()) if rangeErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: rangeErr.Detail, Source: &jsonapi.ErrorSource{Pointer: rangeErr.Pointer}, }}) } if to.Before(from) { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "to must be >= from", Source: &jsonapi.ErrorSource{Pointer: "/query/to"}, }}) } baseName := strings.TrimSpace(c.Query("base_name")) grouped, err := loadRosterGroups(c.UserContext(), h.svc, baseID, from, to, baseName, "hems") if err != nil { status, errObj := buildServiceErrorObject("List failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } h.attachFlightIDsToHEMSGroups(c.UserContext(), grouped) return response.WriteWithMeta(c, fiber.StatusOK, grouped, map[string]any{"from": from.UTC().Format("2006-01-02"), "to": to.UTC().Format("2006-01-02"), "timezone": "UTC", "total_bases": len(grouped)}) } // ListDutyRosterDatatable godoc // @Summary List HEMS duty roster (datatable) // @Description Datatable format with grouped rows and pagination metadata. // @Description Query: `hems_base_id`, `base_name`, `from`, `to`, `page`, `size`, `limit`, `draw`. // @Description Response grouping key is `attributes.hems_bases`. // @Tags Flights - HEMS Duty Roster // @Produce json // @Param hems_base_id query string false "HEMS base UUID" // @Param base_name query string false "Filter by HEMS base name (contains, case-insensitive)" // @Param from query string false "Start date (YYYY-MM-DD)" // @Param to query string false "End date (YYYY-MM-DD)" // @Param page query int false "Page number" // @Param size query int false "Page size" // @Param limit query int false "Page size override" // @Param draw query int false "Draw counter" // @Success 200 {object} responsedto.DutyRosterGroupedDataTableResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/hems-roster/get-all/dt func (h *DutyRosterHandler) ListDatatable(c *fiber.Ctx) error { hemsBaseRaw := strings.TrimSpace(c.Query("hems_base_id")) var baseID []byte var err error if hemsBaseRaw != "" { baseID, err = uuidv7.ParseString(hemsBaseRaw) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "hems_base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/query/hems_base_id"}, }}) } } from, to, rangeErr := service.ParseDutyRosterDateRangeOrDefault(c.Query("from"), c.Query("to"), time.Now().UTC()) if rangeErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: rangeErr.Detail, Source: &jsonapi.ErrorSource{Pointer: rangeErr.Pointer}, }}) } if to.Before(from) { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "to must be >= from", Source: &jsonapi.ErrorSource{Pointer: "/query/to"}, }}) } baseName := strings.TrimSpace(c.Query("base_name")) pageNumber := parseIntDefault(c.Query("page"), 1) if pageNumber < 1 { pageNumber = 1 } length := parseIntDefault(c.Query("limit"), 0) if length <= 0 { length = parseIntDefault(c.Query("size"), 20) } if length > 100 { length = 100 } if length < 1 { length = 20 } start := (pageNumber - 1) * length draw := parseIntDefault(c.Query("draw"), pageNumber) grouped, err := loadRosterGroups(c.UserContext(), h.svc, baseID, from, to, baseName, "hems") if err != nil { status, errObj := buildServiceErrorObject("List failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } h.attachFlightIDsToHEMSGroups(c.UserContext(), grouped) pagedGroups, total := paginateRosterGroups(grouped, start, length) meta := map[string]any{"draw": draw, "records_total": total, "records_filtered": total, "from": from.UTC().Format("2006-01-02"), "to": to.UTC().Format("2006-01-02"), "timezone": "UTC"} return response.WriteWithMeta(c, fiber.StatusOK, pagedGroups, meta) } // CreateDutyRosterUnified godoc // @Summary Create duty roster // @Description Unified endpoint for all base categories. // @Tags Flights - Duty Roster // @Accept json // @Produce json // @Param request body requestdto.DutyRosterCreateRequest true "Create request payload" // @Success 201 {object} responsedto.DutyRosterResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/duty-roster/create [post] func (h *DutyRosterHandler) CreateAny(c *fiber.Ctx) error { var req requestdto.DutyRosterCreateRequest if err := c.BodyParser(&req); err != nil { return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } baseID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.BaseID)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"}, }}) } baseType, err := h.resolveBaseTypeByBaseID(c.UserContext(), baseID) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "base_id not found") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if baseType == "base" { return h.CreateBase(c) } return h.Create(c) } // ListDutyRosterUnifiedDatatable godoc // @Summary List duty roster (datatable) // @Description Unified datatable endpoint. Optional `base_type` filter: `hems` or `base`. // @Tags Flights - Duty Roster // @Produce json // @Param base_type query string false "Base type filter (hems|base)" // @Param base_id query string false "Base UUID" // @Param base_name query string false "Filter by base name" // @Param from query string false "Start date (YYYY-MM-DD)" // @Param to query string false "End date (YYYY-MM-DD)" // @Param page query int false "Page number" // @Param size query int false "Page size" // @Param limit query int false "Page size override" // @Param draw query int false "Draw counter" // @Success 200 {object} responsedto.DutyRosterGroupedDataTableResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/duty-roster/get-all/dt [get] func (h *DutyRosterHandler) ListAnyDatatable(c *fiber.Ctx) error { if strings.EqualFold(strings.TrimSpace(c.Query("base_type")), "base") { return h.ListBaseDatatable(c) } return h.ListDatatable(c) } // DeleteDutyRosterUnified godoc // @Summary Delete duty roster // @Description Unified delete endpoint by `data.id`. // @Tags Flights - Duty Roster // @Accept json // @Produce json // @Param request body requestdto.DutyRosterAnyDeleteRequest true "Delete request payload" // @Success 200 {object} dto.GenericDeleteResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/duty-roster/delete [delete] func (h *DutyRosterHandler) DeleteAny(c *fiber.Ctx) error { var req struct { Data struct { ID string `json:"id"` } `json:"data"` } if err := c.BodyParser(&req); err != nil { return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } id, err := h.resolveDutyRosterIDFromInput(c, strings.TrimSpace(req.Data.ID)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: safeInternalDetail(), Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil { status, errObj := buildServiceErrorObject("Delete failed", err, "duty roster not found") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "duty_roster_delete", Attributes: map[string]any{"deleted": true}}) } // DeleteDutyRosterUnifiedCrew godoc // @Summary Remove duty roster crew assignment // @Description Unified crew delete endpoint by `data.id` and `roster_detail`. // @Tags Flights - Duty Roster // @Accept json // @Produce json // @Param request body requestdto.DutyRosterDeleteCrewByIDRequest true "Crew delete request payload" // @Success 200 {object} responsedto.DutyRosterCrewDeleteResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/duty-roster/crew [delete] func (h *DutyRosterHandler) DeleteAnyCrew(c *fiber.Ctx) error { var req struct { Data struct { ID string `json:"id"` } `json:"data"` } if err := c.BodyParser(&req); err != nil { return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } id, err := h.resolveDutyRosterIDFromInput(c, strings.TrimSpace(req.Data.ID)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: safeInternalDetail(), Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } crews, parseErr := service.BuildDutyRosterDeleteCrews(c.Body(), actorUserID(c)) if parseErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: parseErr.Detail, Source: &jsonapi.ErrorSource{Pointer: parseErr.Pointer}, }}) } if len(crews) == 0 { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "roster_detail is required", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/roster_detail"}, }}) } deletedRows, delErr := h.svc.DeleteCrews(c.UserContext(), id, crews, actorUserID(c)) if delErr != nil { status, errObj := buildServiceErrorObject("Delete failed", delErr, "duty roster not found") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if len(deletedRows) == 0 { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "no crew assignment matched delete filter", }}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "duty_roster_crew_delete", Attributes: map[string]any{"deleted": true, "deleted_count": len(deletedRows), "deleted_items": dutyRosterDeletedCrewItems(deletedRows)}}) } // CreateBaseDutyRoster godoc // @Summary Create Base duty roster // @Description Create roster by regular `base_id` and `duty_date`. // @Description Base endpoints use `base_id` in request and `bases` in response attributes. // @Description Time input accepts `HH:MM` or `HH:MM:SS` and is normalized. // @Description `roster_detail` buckets: `pilot`, `doctor`, `rescuer`, `helper`, `other_person`. // @Description For pilot: `crew_type` can be `main` or `additional`. // @Description Pilot flags (`flight_instructor`, `line_checker`, `supervisor`, `examiner`, `co_pilot`) are only applied when `crew_type=additional`; for `main` they are ignored. // @Description For doctor/rescuer/helper, `crew_type` is always treated as `main`. // @Description For `other_person`, `id` must be empty and `name` is required. // @Description If roster shift_time is omitted, system uses base default shift; fallback `06:00:00-21:00:00`. // @Description `data.type` must be `base_duty_roster_create` (or `base_duty_roster` for backward compatibility). // @Tags Flights - Base Duty Roster // @Accept json // @Produce json // @Param request body requestdto.DutyRosterCreateRequest true "Create request payload" // @Success 201 {object} responsedto.DutyRosterResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/base-roster/create func (h *DutyRosterHandler) CreateBase(c *fiber.Ctx) error { var req requestdto.DutyRosterCreateRequest if err := c.BodyParser(&req); err != nil { return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, errs) } baseID, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.BaseID)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"}, }}) } dutyDate, err := service.ParseDutyRosterDateOnly(req.Data.Attributes.DutyDate) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "duty_date must be YYYY-MM-DD", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/duty_date"}, }}) } row := &dutyroster.DutyRoster{BaseID: baseID, DutyDate: dutyDate, CreatedBy: actorUserID(c), UpdatedBy: actorUserID(c)} defaultShiftStart, defaultShiftEnd, err := h.svc.ResolveBaseDefaultShiftRange(c.UserContext(), baseID, "base") if err != nil { if wrote := h.writeBaseTypeValidationError(c, err, baseID, "base", "/data/attributes/base_id"); wrote { return nil } status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } rowShiftStart, shiftErr := service.NormalizeDutyRosterClockInput(req.Data.Attributes.ShiftTime.ShiftStart, defaultShiftStart, "/data/attributes/shift_time/shift_start") if shiftErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: shiftErr.Detail, Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer}, }}) } rowShiftEnd, shiftErr := service.NormalizeDutyRosterClockInput(req.Data.Attributes.ShiftTime.ShiftEnd, defaultShiftEnd, "/data/attributes/shift_time/shift_end") if shiftErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: shiftErr.Detail, Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer}, }}) } _ = rowShiftStart _ = rowShiftEnd detail := toCoreDutyRosterDetailsInput(req.Data.Attributes.Detail) crews, crewErr := service.BuildDutyRosterCrewRows(detail, actorUserID(c)) if crewErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: crewErr.Detail, Source: &jsonapi.ErrorSource{Pointer: crewErr.Pointer}, }}) } clearCrewShiftDateForCreate(crews) if err := h.svc.Create(c.UserContext(), row, crews); err != nil { status, errObj := buildServiceErrorObject("Create failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } flightID, err := h.createFlightForBaseRoster(c, row) if err != nil { _ = h.svc.HardDelete(c.UserContext(), row.ID) return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Create failed", Detail: safeInternalDetail(), }}) } return h.respondByIDBaseTypeWithFlight(c, fiber.StatusCreated, row.ID, "base", flightID) } func (h *DutyRosterHandler) createFlightForBaseRoster(c *fiber.Ctx, roster *dutyroster.DutyRoster) ([]byte, error) { if h.flight == nil { return nil, nil } flightRow := &flight.Flight{ Date: roster.DutyDate.UTC(), DutyRosterID: append([]byte(nil), roster.ID...), CreatedBy: actorUserID(c), UpdatedBy: actorUserID(c), } if err := h.flight.Create(c.UserContext(), flightRow); err != nil { return nil, err } return flightRow.ID, nil } // GetBaseDutyRoster godoc // @Summary Get Base duty roster by UUID // @Tags Flights - Base Duty Roster // @Produce json // @Param uuid path string true "Roster UUID" // @Success 200 {object} responsedto.DutyRosterResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/base-roster/get/{uuid} func (h *DutyRosterHandler) GetBase(c *fiber.Ctx) error { id, err := uuidv7.ParseString(dutyRosterPathID(c)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) } // Backward-compatible fallback: allow :uuid as flight_uuid. if h.flight != nil { flightRow, flightErr := h.flight.GetByID(c.UserContext(), id) if flightErr != nil { status, errObj := buildServiceErrorObject("Get failed", flightErr, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if flightRow != nil && len(flightRow.DutyRosterID) == 16 { return h.respondByIDBaseTypeWithFlight(c, fiber.StatusOK, flightRow.DutyRosterID, "base", flightRow.ID) } } return h.respondByIDBaseType(c, fiber.StatusOK, id, "base") } func (h *DutyRosterHandler) GetBaseByAuthUser(c *fiber.Ctx) error { userID := actorUserID(c) if len(userID) == 0 { return writeDutyRosterErrors(c, fiber.StatusUnauthorized, []jsonapi.ErrorObject{{ Status: "401", Title: "Unauthorized", Detail: "missing auth context", }}) } rosterID, err := h.findLatestRosterIDByUser(c.UserContext(), userID, "base") if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if len(rosterID) != 16 { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "base duty roster not found", }}) } return h.respondByIDBaseType(c, fiber.StatusOK, rosterID, "base") } // UpdateBaseDutyRoster godoc // @Summary Update Base duty roster (partial) // @Description If `roster_detail` is omitted, only header is updated. // @Description Crew update is additive by default; same role+crew_type+user in same roster is replaced. // @Description For pilot rows, extra flags (`flight_instructor`, `line_checker`, `supervisor`, `examiner`, `co_pilot`) are only applied for `crew_type=additional`. // @Description `data.type` must be `base_duty_roster_update` (or `base_duty_roster` for backward compatibility). // @Tags Flights - Base Duty Roster // @Accept json // @Produce json // @Param uuid path string true "Roster UUID" // @Param request body requestdto.DutyRosterUpdateRequest true "Update request payload" // @Success 200 {object} responsedto.DutyRosterResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/base-roster/update/{uuid} func (h *DutyRosterHandler) UpdateBase(c *fiber.Ctx) error { var req requestdto.DutyRosterUpdateRequest if err := c.BodyParser(&req); err != nil { return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, errs) } uuidStr := strings.TrimSpace(dutyRosterPathID(c)) bodyID := strings.TrimSpace(req.Data.ID) if uuidStr == "" && bodyID == "" { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is required", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } if uuidStr != "" && bodyID != "" && bodyID != uuidStr { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id does not match path id", Source: &jsonapi.ErrorSource{Pointer: "/data/id"}, }}) } targetID := bodyID if targetID == "" { targetID = uuidStr } id, err := h.resolveDutyRosterIDFromInput(c, targetID) if err != nil { sourcePtr := "/data/id" if targetID == uuidStr && uuidStr != "" && bodyID == "" { sourcePtr = "/path/id" } return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: safeInternalDetail(), Source: &jsonapi.ErrorSource{Pointer: sourcePtr}, }}) } existing, err := h.svc.GetHeaderByIDByBaseType(c.UserContext(), id, "base") if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if existing == nil { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "base duty roster not found", }}) } row := &dutyroster.DutyRoster{ID: id, BaseID: append([]byte(nil), existing.BaseID...), DutyDate: existing.DutyDate, UpdatedBy: actorUserID(c)} attrs := req.Data.Attributes if attrs.BaseID != nil && strings.TrimSpace(*attrs.BaseID) != "" { baseID, err := uuidv7.ParseString(strings.TrimSpace(*attrs.BaseID)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"}, }}) } row.BaseID = baseID } if attrs.DutyDate != nil && strings.TrimSpace(*attrs.DutyDate) != "" { d, err := service.ParseDutyRosterDateOnly(*attrs.DutyDate) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "duty_date must be YYYY-MM-DD", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/duty_date"}, }}) } row.DutyDate = d } if attrs.ShiftTime != nil { defaultShiftStart, defaultShiftEnd, err := service.ResolveDutyRosterShiftDefaultsForUpdate(c.UserContext(), h.svc, row.BaseID, "base", "", "") if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } shiftStart, shiftErr := service.NormalizeDutyRosterClockInput(attrs.ShiftTime.ShiftStart, defaultShiftStart, "/data/attributes/shift_time/shift_start") if shiftErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: shiftErr.Detail, Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer}, }}) } shiftEnd, shiftErr := service.NormalizeDutyRosterClockInput(attrs.ShiftTime.ShiftEnd, defaultShiftEnd, "/data/attributes/shift_time/shift_end") if shiftErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: shiftErr.Detail, Source: &jsonapi.ErrorSource{Pointer: shiftErr.Pointer}, }}) } _ = shiftStart _ = shiftEnd } crews := []dutyroster.DutyRosterCrew{} if detail := toCoreDutyRosterDetailsInputPtr(attrs.Detail); detail != nil { built, crewErr := service.BuildDutyRosterCrewRows(*detail, actorUserID(c)) if crewErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: crewErr.Detail, Source: &jsonapi.ErrorSource{Pointer: crewErr.Pointer}, }}) } crews = built } if err := h.svc.Update(c.UserContext(), row, crews); err != nil { status, errObj := buildServiceErrorObject("Update failed", err, "base duty roster not found") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } h.logCrewAudit(c, "duty_roster.crew.append", id, "base", crews, "append crew assignments") return h.respondByIDBaseType(c, fiber.StatusOK, id, "base") } // UpdateDutyRosterUnified godoc // @Summary Update duty roster (partial) // @Description Unified update endpoint by `data.id`. // @Tags Flights - Duty Roster // @Accept json // @Produce json // @Param request body requestdto.DutyRosterUpdateRequest true "Update request payload" // @Success 200 {object} responsedto.DutyRosterResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/duty-roster/update [patch] func (h *DutyRosterHandler) UpdateAny(c *fiber.Ctx) error { var req struct { Data struct { Type string `json:"type"` ID string `json:"id"` Attributes struct { BaseID *string `json:"base_id,omitempty"` } `json:"attributes"` } `json:"data"` } if err := c.BodyParser(&req); err != nil { return writeDutyRosterErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: safeInternalDetail(), }}) } if req.Data.Attributes.BaseID != nil && strings.TrimSpace(*req.Data.Attributes.BaseID) != "" { baseID, err := uuidv7.ParseString(strings.TrimSpace(*req.Data.Attributes.BaseID)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base_id"}, }}) } baseType, err := h.resolveBaseTypeByBaseID(c.UserContext(), baseID) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "base_id not found") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if baseType == "base" { return h.UpdateBase(c) } return h.Update(c) } targetID := strings.TrimSpace(req.Data.ID) if targetID != "" { rosterID, err := h.resolveDutyRosterIDFromInput(c, targetID) if err == nil && len(rosterID) == 16 { if hdr, _ := h.svc.GetHeaderByIDByBaseType(c.UserContext(), rosterID, "base"); hdr != nil { return h.UpdateBase(c) } return h.Update(c) } } t := strings.ToLower(strings.TrimSpace(req.Data.Type)) if strings.HasPrefix(t, "base_") { return h.UpdateBase(c) } return h.Update(c) } func (h *DutyRosterHandler) resolveDutyRosterIDFromInput(c *fiber.Ctx, rawID string) ([]byte, error) { parsedID, err := uuidv7.ParseString(strings.TrimSpace(rawID)) if err != nil { return nil, errors.New("id is invalid UUID") } if h.flight != nil { flightRow, flightErr := h.flight.GetByID(c.UserContext(), parsedID) if flightErr != nil { return nil, flightErr } if flightRow != nil { if len(flightRow.DutyRosterID) != 16 { return nil, errors.New("flight_id is not linked to duty roster") } return append([]byte(nil), flightRow.DutyRosterID...), nil } } // Backward compatibility: allow direct duty_roster_id. return parsedID, nil } func (h *DutyRosterHandler) findLatestRosterIDByUser(ctx context.Context, userID []byte, baseType string) ([]byte, error) { if h.flight == nil || len(userID) != 16 { return nil, nil } rows, _, err := h.flight.ListByUserID(ctx, userID, 100, 0) if err != nil { return nil, err } for i := range rows { if len(rows[i].DutyRosterID) != 16 { continue } header, err := h.svc.GetHeaderByIDByBaseType(ctx, rows[i].DutyRosterID, baseType) if err != nil { return nil, err } if header != nil { return append([]byte(nil), rows[i].DutyRosterID...), nil } } return nil, nil } // DeleteBaseDutyRoster godoc // @Summary Delete Base duty roster // @Tags Flights - Base Duty Roster // @Produce json // @Param uuid path string true "Roster UUID" // @Success 200 {object} dto.GenericDeleteResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/base-roster/delete/{uuid} func (h *DutyRosterHandler) DeleteBase(c *fiber.Ctx) error { id, err := uuidv7.ParseString(dutyRosterPathID(c)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) } if err := h.svc.Delete(c.UserContext(), id, actorUserID(c)); err != nil { status, errObj := buildServiceErrorObject("Delete failed", err, "base duty roster not found") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "base_duty_roster_delete", Attributes: map[string]any{"deleted": true}}) } // DeleteBaseDutyRosterCrew godoc // @Summary Remove crew assignment from Base duty roster // @Description Remove selected crew rows from roster UUID by filter in `roster_detail`. // @Description This endpoint removes crew only; header roster stays. // @Description For operational roles (pilot/doctor/rescuer/helper), if one `shift_date` boundary is sent then both are required. // @Description If `shift_date` is omitted, filter applies to roster day only. // @Description `data.type` should be `base_duty_roster_crew_delete` (also accepts `base_duty_roster_update`/`base_duty_roster` for compatibility). // @Tags Flights - Base Duty Roster // @Accept json // @Produce json // @Param uuid path string true "Roster UUID" // @Param request body requestdto.DutyRosterDeleteCrewRequest true "Crew delete filter payload (use `roster_detail`)" // @Success 200 {object} responsedto.DutyRosterCrewDeleteResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 404 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/base-roster/{uuid}/crew func (h *DutyRosterHandler) DeleteBaseCrew(c *fiber.Ctx) error { id, err := uuidv7.ParseString(dutyRosterPathID(c)) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/id"}, }}) } crews, parseErr := service.BuildDutyRosterDeleteCrews(c.Body(), actorUserID(c)) if parseErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: parseErr.Detail, Source: &jsonapi.ErrorSource{Pointer: parseErr.Pointer}, }}) } if len(crews) == 0 { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "roster_detail is required", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/roster_detail"}, }}) } deletedRows, err := h.svc.DeleteCrews(c.UserContext(), id, crews, actorUserID(c)) if err != nil { status, errObj := buildServiceErrorObject("Delete failed", err, "base duty roster not found") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if len(deletedRows) == 0 { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "no crew assignment matched delete filter", }}) } h.logCrewAudit(c, "duty_roster.crew.remove", id, "base", crews, "remove crew assignments") return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "base_duty_roster_crew_delete", Attributes: map[string]any{"deleted": true, "deleted_count": len(deletedRows), "deleted_items": dutyRosterDeletedCrewItems(deletedRows)}}) } // ListBaseDutyRoster godoc // @Summary List Base duty roster // @Description Query: `base_id` (optional), `base_name` (optional), `from` and `to` (`YYYY-MM-DD`, optional). // @Description Response grouping key is `attributes.bases`. // @Tags Flights - Base Duty Roster // @Produce json // @Param base_id query string false "Base UUID" // @Param base_name query string false "Filter by base name (contains, case-insensitive)" // @Param from query string false "Start date (YYYY-MM-DD)" // @Param to query string false "End date (YYYY-MM-DD)" // @Success 200 {object} responsedto.DutyRosterGroupedListResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/base-roster/get-all func (h *DutyRosterHandler) ListBase(c *fiber.Ctx) error { baseRaw := strings.TrimSpace(c.Query("base_id")) var baseID []byte var err error if baseRaw != "" { baseID, err = uuidv7.ParseString(baseRaw) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/query/base_id"}, }}) } } from, to, rangeErr := service.ParseDutyRosterDateRangeOrDefault(c.Query("from"), c.Query("to"), time.Now().UTC()) if rangeErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: rangeErr.Detail, Source: &jsonapi.ErrorSource{Pointer: rangeErr.Pointer}, }}) } if to.Before(from) { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "to must be >= from", Source: &jsonapi.ErrorSource{Pointer: "/query/to"}, }}) } baseName := strings.TrimSpace(c.Query("base_name")) grouped, err := loadRosterGroups(c.UserContext(), h.svc, baseID, from, to, baseName, "base") if err != nil { status, errObj := buildServiceErrorObject("List failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } h.attachFlightIDsToBaseGroups(c.UserContext(), grouped) return response.WriteWithMeta(c, fiber.StatusOK, grouped, map[string]any{"from": from.UTC().Format("2006-01-02"), "to": to.UTC().Format("2006-01-02"), "timezone": "UTC", "total_bases": len(grouped)}) } // ListBaseDutyRosterDatatable godoc // @Summary List Base duty roster (datatable) // @Description Query: `base_id`, `base_name`, `from`, `to`, `page`, `size`, `limit`, `draw`. // @Description Response grouping key is `attributes.bases`. // @Tags Flights - Base Duty Roster // @Produce json // @Param base_id query string false "Base UUID" // @Param base_name query string false "Filter by base name (contains, case-insensitive)" // @Param from query string false "Start date (YYYY-MM-DD)" // @Param to query string false "End date (YYYY-MM-DD)" // @Param page query int false "Page number" // @Param size query int false "Page size" // @Param limit query int false "Page size override" // @Param draw query int false "Draw counter" // @Success 200 {object} responsedto.DutyRosterGroupedDataTableResponse // @Failure 422 {object} dto.ErrorResponse // @Failure 400 {object} dto.ErrorResponse // Legacy docs route removed: /api/v1/base-roster/get-all/dt func (h *DutyRosterHandler) ListBaseDatatable(c *fiber.Ctx) error { baseRaw := strings.TrimSpace(c.Query("base_id")) var baseID []byte var err error if baseRaw != "" { baseID, err = uuidv7.ParseString(baseRaw) if err != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "base_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/query/base_id"}, }}) } } from, to, rangeErr := service.ParseDutyRosterDateRangeOrDefault(c.Query("from"), c.Query("to"), time.Now().UTC()) if rangeErr != nil { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: rangeErr.Detail, Source: &jsonapi.ErrorSource{Pointer: rangeErr.Pointer}, }}) } if to.Before(from) { return writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "to must be >= from", Source: &jsonapi.ErrorSource{Pointer: "/query/to"}, }}) } baseName := strings.TrimSpace(c.Query("base_name")) pageNumber := parseIntDefault(c.Query("page"), 1) if pageNumber < 1 { pageNumber = 1 } length := parseIntDefault(c.Query("limit"), 0) if length <= 0 { length = parseIntDefault(c.Query("size"), 20) } if length > 100 { length = 100 } if length < 1 { length = 20 } start := (pageNumber - 1) * length draw := parseIntDefault(c.Query("draw"), pageNumber) grouped, err := loadRosterGroups(c.UserContext(), h.svc, baseID, from, to, baseName, "base") if err != nil { status, errObj := buildServiceErrorObject("List failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } h.attachFlightIDsToBaseGroups(c.UserContext(), grouped) pagedGroups, total := paginateRosterGroups(grouped, start, length) meta := map[string]any{"draw": draw, "records_total": total, "records_filtered": total, "from": from.UTC().Format("2006-01-02"), "to": to.UTC().Format("2006-01-02"), "timezone": "UTC"} return response.WriteWithMeta(c, fiber.StatusOK, pagedGroups, meta) } func (h *DutyRosterHandler) respondByIDBaseType(c *fiber.Ctx, status int, id []byte, baseType string) error { return h.respondByIDBaseTypeWithFlight(c, status, id, baseType, nil) } func (h *DutyRosterHandler) respondByIDBaseTypeWithFlight(c *fiber.Ctx, status int, id []byte, baseType string, flightID []byte) error { header, err := h.svc.GetHeaderByIDByBaseType(c.UserContext(), id, baseType) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if header == nil { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "duty roster not found", }}) } crews, err := h.svc.GetCrewsByRosterIDs(c.UserContext(), [][]byte{id}) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } flightMeta := rosterFlightMeta{} if len(flightID) == 16 { flightMeta.FlightID = idString(flightID) } else { flightMeta, _ = h.findFlightMetaByRosterID(c.UserContext(), id) } if strings.EqualFold(strings.TrimSpace(baseType), "base") { res := dutyRosterResource(c.UserContext(), *header, crews, "base") if strings.TrimSpace(flightMeta.FlightID) != "" { res.Attributes.FlightID = strings.TrimSpace(flightMeta.FlightID) } if !isEmptyRosterHelicopter(flightMeta.Helicopter) { res.Attributes.Helicopter = flightMeta.Helicopter } return response.Write(c, status, res) } res := dutyRosterResource(c.UserContext(), *header, crews, "hems") if strings.TrimSpace(flightMeta.FlightID) != "" { res.Attributes.FlightID = strings.TrimSpace(flightMeta.FlightID) } if !isEmptyRosterHelicopter(flightMeta.Helicopter) { res.Attributes.Helicopter = flightMeta.Helicopter } return response.Write(c, status, res) } func (h *DutyRosterHandler) respondByID(c *fiber.Ctx, status int, id []byte) error { header, err := h.svc.GetHeaderByID(c.UserContext(), id) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } if header == nil { return writeDutyRosterErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{ Status: "404", Title: "Not found", Detail: "hems duty roster not found", }}) } crews, err := h.svc.GetCrewsByRosterIDs(c.UserContext(), [][]byte{id}) if err != nil { status, errObj := buildServiceErrorObject("Get failed", err, "") return writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) } res := dutyRosterResource(c.UserContext(), *header, crews, "hems") if flightMeta, findErr := h.findFlightMetaByRosterID(c.UserContext(), id); findErr == nil { if strings.TrimSpace(flightMeta.FlightID) != "" { res.Attributes.FlightID = strings.TrimSpace(flightMeta.FlightID) } if !isEmptyRosterHelicopter(flightMeta.Helicopter) { res.Attributes.Helicopter = flightMeta.Helicopter } } return response.Write(c, status, res) } func (h *DutyRosterHandler) findFlightMetaByRosterID(ctx context.Context, rosterID []byte) (rosterFlightMeta, error) { if h.flight == nil || len(rosterID) != 16 { return rosterFlightMeta{}, nil } row, err := h.flight.GetByDutyRosterID(ctx, rosterID) if err != nil || row == nil { return rosterFlightMeta{}, err } return rosterFlightMeta{ FlightID: idString(row.ID), Helicopter: buildRosterHelicopter(*row), }, nil } func (h *DutyRosterHandler) buildFlightMetaByRosterMap(ctx context.Context, rosterIDs [][]byte) map[string]rosterFlightMeta { out := map[string]rosterFlightMeta{} if h.flight == nil || len(rosterIDs) == 0 { return out } rows, err := h.flight.ListByDutyRosterIDs(ctx, rosterIDs) if err != nil { return out } for i := range rows { rosterKey := idString(rows[i].DutyRosterID) if rosterKey == "" { continue } next := rosterFlightMeta{ FlightID: idString(rows[i].ID), Helicopter: buildRosterHelicopter(rows[i]), } prev, exists := out[rosterKey] if !exists || shouldReplaceRosterFlightMeta(prev, next) { out[rosterKey] = next } } return out } func (h *DutyRosterHandler) attachFlightIDsToHEMSGroups(ctx context.Context, groups []responsedto.DutyRosterBaseGroupResource) { rosterIDs := make([][]byte, 0) for i := range groups { for j := range groups[i].Attributes.Rosters { id, err := uuidv7.ParseString(strings.TrimSpace(groups[i].Attributes.Rosters[j].ID)) if err == nil { rosterIDs = append(rosterIDs, id) } } } flightByRoster := h.buildFlightMetaByRosterMap(ctx, rosterIDs) for i := range groups { for j := range groups[i].Attributes.Rosters { rosterID := strings.TrimSpace(groups[i].Attributes.Rosters[j].ID) meta := flightByRoster[rosterID] if strings.TrimSpace(meta.FlightID) != "" { groups[i].Attributes.Rosters[j].FlightID = strings.TrimSpace(meta.FlightID) } if !isEmptyRosterHelicopter(meta.Helicopter) { groups[i].Attributes.Rosters[j].Helicopter = meta.Helicopter } } } } func (h *DutyRosterHandler) attachFlightIDsToBaseGroups(ctx context.Context, groups []responsedto.DutyRosterBaseGroupResource) { rosterIDs := make([][]byte, 0) for i := range groups { for j := range groups[i].Attributes.Rosters { id, err := uuidv7.ParseString(strings.TrimSpace(groups[i].Attributes.Rosters[j].ID)) if err == nil { rosterIDs = append(rosterIDs, id) } } } flightByRoster := h.buildFlightMetaByRosterMap(ctx, rosterIDs) for i := range groups { for j := range groups[i].Attributes.Rosters { rosterID := strings.TrimSpace(groups[i].Attributes.Rosters[j].ID) meta := flightByRoster[rosterID] if strings.TrimSpace(meta.FlightID) != "" { groups[i].Attributes.Rosters[j].FlightID = strings.TrimSpace(meta.FlightID) } if !isEmptyRosterHelicopter(meta.Helicopter) { groups[i].Attributes.Rosters[j].Helicopter = meta.Helicopter } } } } func buildRosterHelicopter(row flight.Flight) responsedto.DutyRosterHelicopterSummary { if row.Takeover == nil { return responsedto.DutyRosterHelicopterSummary{} } return responsedto.DutyRosterHelicopterSummary{ ID: idString(row.Takeover.ReserveAcID), } } func shouldReplaceRosterFlightMeta(prev, next rosterFlightMeta) bool { if isEmptyRosterHelicopter(prev.Helicopter) && !isEmptyRosterHelicopter(next.Helicopter) { return true } if !isEmptyRosterHelicopter(prev.Helicopter) && isEmptyRosterHelicopter(next.Helicopter) { return false } if strings.TrimSpace(prev.FlightID) == "" && strings.TrimSpace(next.FlightID) != "" { return true } return false } func isEmptyRosterHelicopter(h responsedto.DutyRosterHelicopterSummary) bool { return strings.TrimSpace(h.ID) == "" && strings.TrimSpace(h.Designation) == "" && strings.TrimSpace(h.Identifier) == "" && strings.TrimSpace(h.Type) == "" } func dateToString(t *time.Time) string { if t == nil || t.IsZero() { return "" } return t.UTC().Format("2006-01-02") } func bytesEqualUUID(a, b []byte) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func dutyRosterPathID(c *fiber.Ctx) string { id := strings.TrimSpace(c.Params("id")) if id != "" { return id } return strings.TrimSpace(c.Params("uuid")) } func dutyRosterPathFlightID(c *fiber.Ctx) string { id := strings.TrimSpace(c.Params("flight_id")) if id != "" { return id } return strings.TrimSpace(c.Params("flight_uuid")) } func writeDutyRosterErrors(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 if strings.TrimSpace(e.Code) == "" || strings.TrimSpace(e.ErrorCode) == "" { code, errorCode := inferDutyRosterErrorCode(status, e.Title, e.Detail) if strings.TrimSpace(e.Code) == "" { e.Code = code } if strings.TrimSpace(e.ErrorCode) == "" { e.ErrorCode = errorCode } } enriched = append(enriched, e) } return response.WriteErrors(c, status, enriched) } func inferDutyRosterErrorCode(status int, title, detail string) (string, string) { lowerTitle := strings.ToLower(strings.TrimSpace(title)) lowerDetail := strings.ToLower(strings.TrimSpace(detail)) switch status { case fiber.StatusUnauthorized: return apperrorsx.CodeDutyRosterUnauthorized, apperrorsx.ErrDutyRosterUnauthorized.ErrorCode case fiber.StatusNotFound: return apperrorsx.CodeDutyRosterNotFound, apperrorsx.ErrDutyRosterNotFound.ErrorCode case fiber.StatusConflict: if strings.Contains(lowerDetail, "cannot delete") || strings.Contains(lowerDetail, "relation") || strings.Contains(lowerDetail, "foreign key") || strings.Contains(lowerDetail, "constraint") { return apperrorsx.CodeDutyRosterRelationDeleteConflict, apperrorsx.ErrDutyRosterRelationDeleteConflict.ErrorCode } return apperrorsx.CodeDutyRosterConflict, apperrorsx.ErrDutyRosterConflict.ErrorCode case fiber.StatusUnprocessableEntity: switch { case strings.Contains(lowerDetail, "uuid"), strings.Contains(lowerDetail, "invalid id"): return apperrorsx.CodeDutyRosterInvalidUUID, apperrorsx.ErrDutyRosterInvalidUUID.ErrorCode case strings.Contains(lowerDetail, "base type"), strings.Contains(lowerDetail, "not hems base"), strings.Contains(lowerDetail, "not regular base"): return apperrorsx.CodeDutyRosterBaseTypeInvalid, apperrorsx.ErrDutyRosterBaseTypeInvalid.ErrorCode case strings.Contains(lowerTitle, "validation error"): return apperrorsx.CodeDutyRosterValidationFailed, apperrorsx.ErrDutyRosterValidationFailed.ErrorCode default: return apperrorsx.CodeDutyRosterInvalidPayload, apperrorsx.ErrDutyRosterInvalidPayload.ErrorCode } case fiber.StatusBadRequest: switch { case strings.Contains(lowerTitle, "invalid json"): return apperrorsx.CodeDutyRosterInvalidJSON, apperrorsx.ErrDutyRosterInvalidJSON.ErrorCode case strings.Contains(lowerTitle, "create failed"): return apperrorsx.CodeDutyRosterCreateFailed, apperrorsx.ErrDutyRosterCreateFailed.ErrorCode case strings.Contains(lowerTitle, "update failed"): return apperrorsx.CodeDutyRosterUpdateFailed, apperrorsx.ErrDutyRosterUpdateFailed.ErrorCode case strings.Contains(lowerTitle, "delete failed"): return apperrorsx.CodeDutyRosterDeleteFailed, apperrorsx.ErrDutyRosterDeleteFailed.ErrorCode case strings.Contains(lowerTitle, "list failed"): return apperrorsx.CodeDutyRosterListFailed, apperrorsx.ErrDutyRosterListFailed.ErrorCode default: return apperrorsx.CodeDutyRosterInvalidPayload, apperrorsx.ErrDutyRosterInvalidPayload.ErrorCode } default: return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode } } func buildServiceErrorObject(title string, err error, notFoundDetail string) (int, jsonapi.ErrorObject) { if err == nil { status := fiber.StatusBadRequest return status, jsonapi.ErrorObject{ Status: strconv.Itoa(status), Title: title, Detail: title, } } detail := strings.TrimSpace(err.Error()) if errors.Is(err, gorm.ErrRecordNotFound) { status := fiber.StatusNotFound if strings.TrimSpace(notFoundDetail) != "" { detail = strings.TrimSpace(notFoundDetail) } else if detail == "" { detail = "resource not found" } return status, jsonapi.ErrorObject{ Status: strconv.Itoa(status), Title: title, Detail: detail, } } if errors.Is(err, context.Canceled) { status := fiber.StatusRequestTimeout if detail == "" { detail = "request canceled" } return status, jsonapi.ErrorObject{ Status: strconv.Itoa(status), Title: title, Detail: detail, } } if errors.Is(err, context.DeadlineExceeded) { status := fiber.StatusRequestTimeout if detail == "" { detail = "request timeout" } return status, jsonapi.ErrorObject{ Status: strconv.Itoa(status), Title: title, Detail: detail, } } status, detail, mysqlMapped := mapDutyRosterMySQLError(err, detail) if !mysqlMapped { status, detail = mapDutyRosterGenericError(detail, title) } if detail == "" { detail = title } return status, jsonapi.ErrorObject{ Status: strconv.Itoa(status), Title: title, Detail: detail, } } func (h *DutyRosterHandler) writeBaseTypeValidationError( c *fiber.Ctx, err error, baseID []byte, expectedBaseType string, pointer string, ) bool { if !errors.Is(err, gorm.ErrRecordNotFound) { return false } var oppositeType string var mismatchDetail string switch strings.ToLower(strings.TrimSpace(expectedBaseType)) { case "base": oppositeType = "hems" mismatchDetail = "base_id is not regular base" default: oppositeType = "base" mismatchDetail = "base_id is not HEMS base" } rows, listErr := h.svc.ListBasesByType(c.UserContext(), baseID, expectedBaseType) if listErr != nil { status, errObj := buildServiceErrorObject("Get failed", listErr, "") _ = writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) return true } if len(rows) > 0 { return false } oppositeRows, oppositeErr := h.svc.ListBasesByType(c.UserContext(), baseID, oppositeType) if oppositeErr != nil { status, errObj := buildServiceErrorObject("Get failed", oppositeErr, "") _ = writeDutyRosterErrors(c, status, []jsonapi.ErrorObject{errObj}) return true } if len(oppositeRows) > 0 { _ = writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: mismatchDetail, Source: &jsonapi.ErrorSource{Pointer: pointer}, }}) return true } _ = writeDutyRosterErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "base_id not found", Source: &jsonapi.ErrorSource{Pointer: pointer}, }}) return true } func (h *DutyRosterHandler) resolveBaseTypeByBaseID(ctx context.Context, baseID []byte) (string, error) { rows, err := h.svc.ListBasesByType(ctx, baseID, "hems") if err != nil { return "", err } if len(rows) > 0 { return "hems", nil } rows, err = h.svc.ListBasesByType(ctx, baseID, "base") if err != nil { return "", err } if len(rows) > 0 { return "base", nil } return "", gorm.ErrRecordNotFound } func mapDutyRosterMySQLError(err error, fallbackDetail string) (int, string, bool) { status := fiber.StatusBadRequest detail := strings.TrimSpace(fallbackDetail) var mysqlErr *mysqldriver.MySQLError if !errors.As(err, &mysqlErr) { return status, detail, false } switch mysqlErr.Number { case 1045: status = fiber.StatusUnauthorized detail = "authentication failed" case 1142, 1227: status = fiber.StatusForbidden detail = "insufficient permission to perform this action" case 1205, 1213: status = fiber.StatusConflict detail = "request conflicted with another transaction; please retry" case 2006: status = fiber.StatusServiceUnavailable detail = "service temporarily unavailable" case 1062: status = fiber.StatusConflict detail = dutyRosterDuplicateDetail(mysqlErr.Message) case 1048, 1292, 1366, 1451, 1452, 3819, 4025: status = fiber.StatusUnprocessableEntity detail = dutyRosterFriendlyMySQLError(mysqlErr.Number) default: status = fiber.StatusBadRequest detail = "invalid request payload" } return status, detail, true } func mapDutyRosterGenericError(detail, title string) (int, string) { msg := strings.ToLower(strings.TrimSpace(detail)) cleanDetail := strings.TrimSpace(detail) switch { case strings.Contains(msg, "forbidden"), strings.Contains(msg, "permission denied"), strings.Contains(msg, "not allowed"): if cleanDetail != "" { return fiber.StatusForbidden, cleanDetail } return fiber.StatusForbidden, "forbidden" case strings.Contains(msg, "unauthorized"), strings.Contains(msg, "invalid token"), strings.Contains(msg, "authentication failed"): if cleanDetail != "" { return fiber.StatusUnauthorized, cleanDetail } return fiber.StatusUnauthorized, "unauthorized" case strings.Contains(msg, "too many requests"), strings.Contains(msg, "rate limit"): if cleanDetail != "" { return fiber.StatusTooManyRequests, cleanDetail } return fiber.StatusTooManyRequests, "too many requests" case strings.Contains(msg, "conflict"), strings.Contains(msg, "already exists"), strings.Contains(msg, "duplicate"): if cleanDetail != "" { return fiber.StatusConflict, cleanDetail } return fiber.StatusConflict, "data conflict" case strings.Contains(msg, "internal server error"), strings.Contains(msg, "panic"): return fiber.StatusInternalServerError, "internal server error" case strings.Contains(msg, "unavailable"), strings.Contains(msg, "connection refused"): return fiber.StatusServiceUnavailable, "service temporarily unavailable" default: _ = title if cleanDetail != "" { return fiber.StatusBadRequest, cleanDetail } return fiber.StatusBadRequest, "invalid request payload" } } var mysqlDuplicateKeyRe = regexp.MustCompile(`(?i)Duplicate entry '.*' for key '([^']+)'`) func dutyRosterDuplicateDetail(raw string) string { key := mysqlDuplicateKey(raw) if key == "" { return "data conflict: a record with the same unique combination already exists" } switch key { case "idx_roster_base_date": return "roster already exists for the selected base and duty date" case "idx_roster_crew_unique": return "crew assignment already exists for the selected role and shift range" default: return "data conflict: a record with the same unique combination already exists" } } func mysqlDuplicateKey(raw string) string { msg := strings.TrimSpace(raw) if msg == "" { return "" } m := mysqlDuplicateKeyRe.FindStringSubmatch(msg) if len(m) < 2 { return "" } key := strings.TrimSpace(m[1]) if i := strings.LastIndex(key, "."); i >= 0 && i+1 < len(key) { key = key[i+1:] } return strings.Trim(key, "`") } func dutyRosterFriendlyMySQLError(code uint16) string { switch code { case 1048: return "required field must not be empty" case 1292: return "invalid date or time format" case 1366: return "invalid value for one of the fields" case 1451: return "cannot delete data because it is still referenced" case 1452: return "referenced data was not found" case 3819, 4025: return "data violates one of the validation constraints" default: return "invalid request payload" } } func (h *DutyRosterHandler) logCrewAudit( c *fiber.Ctx, action string, rosterID []byte, baseType string, crews []dutyroster.DutyRosterCrew, message string, ) { if h.audit == nil || len(crews) == 0 { return } h.audit.Log(c.UserContext(), service.AuditLogInput{ RequestID: requestIDFromCtx(c), ActorUserID: actorUserID(c), Layer: "handler", Module: "duty_roster", Action: action, Method: c.Method(), Path: c.Path(), StatusCode: fiber.StatusOK, Success: true, IP: c.IP(), UserAgent: c.Get("User-Agent"), Message: message, Metadata: dutyRosterCrewAuditMetadata(rosterID, baseType, crews), }) } func requestIDFromCtx(c *fiber.Ctx) string { if v, ok := c.Locals("requestid").(string); ok { return strings.TrimSpace(v) } return strings.TrimSpace(c.Get("X-Request-ID")) } func clearCrewShiftDateForCreate(crews []dutyroster.DutyRosterCrew) { for i := range crews { crews[i].DateStart = nil crews[i].DateEnd = nil } }