2389 lines
87 KiB
Go
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
|
|
}
|
|
}
|