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

2389 lines
87 KiB
Go

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
}
}