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

2071 lines
72 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/domain/base"
filemanager "wucher/internal/domain/file_manager"
appservice "wucher/internal/service"
"wucher/internal/shared/pkg/apperrors"
"wucher/internal/shared/pkg/apperrorsx"
"wucher/internal/shared/pkg/attachmentresolver"
"wucher/internal/shared/pkg/userctx"
"wucher/internal/shared/pkg/uuidv7"
"wucher/internal/transport/http/dto"
"wucher/internal/transport/http/jsonapi"
"wucher/internal/transport/http/response"
"wucher/internal/transport/http/validators"
)
const (
basePhotoAttachmentRefType = "base_photo"
basePhotoAttachmentCategory = "photo"
)
type basePresignStorage interface {
PresignGetObject(ctx context.Context, objectKey string, ttl time.Duration) (string, error)
}
type BaseHandler struct {
svc base.Service
validate *validators.Validator
fileManager filemanager.Service
storage basePresignStorage
}
type baseDetailedWriter interface {
CreateDetailed(context.Context, appservice.BaseCreateInput) (*appservice.BaseWriteResult, error)
UpdateDetailed(context.Context, appservice.BaseUpdateInput) (*appservice.BaseWriteResult, error)
}
func NewBaseHandler(svc base.Service) *BaseHandler {
return &BaseHandler{svc: svc, validate: validators.New()}
}
func (h *BaseHandler) WithFileManagerService(fileManager filemanager.Service) *BaseHandler {
h.fileManager = fileManager
return h
}
func (h *BaseHandler) WithFileStorage(storage basePresignStorage) *BaseHandler {
h.storage = storage
return h
}
func (h *BaseHandler) WithFileResolver(fileManager filemanager.Service, storage basePresignStorage) *BaseHandler {
h.fileManager = fileManager
h.storage = storage
return h
}
func (h *BaseHandler) createBaseWithService(c *fiber.Ctx, svc baseDetailedWriter) bool {
var req dto.BaseCreateRequest
if err := c.BodyParser(&req); err != nil {
_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: safeInternalDetail(),
}})
return true
}
categoryType, errObj := resolveBaseCategoryType(c, req.Data.Attributes.BaseCategory, "/data/attributes/base_category")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
if errObj := validateBaseCoordinates(req.Data.Attributes.Latitude, req.Data.Attributes.Longitude, "/data/attributes/latitude", "/data/attributes/longitude"); errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
baseName := strings.TrimSpace(req.Data.Attributes.BaseName)
if baseName == "" {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "base is required",
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base"},
}})
return true
}
createdBy, errObj := parseOptionalUUID(req.Data.Attributes.CreatedBy, "/data/attributes/created_by")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
updatedBy, errObj := parseOptionalUUID(req.Data.Attributes.UpdatedBy, "/data/attributes/updated_by")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
defaultStartTimeType, errObj := normalizeBaseShiftTimeType(stringOrDefault(req.Data.Attributes.DefaultStartTimeType, ""), "/data/attributes/default_start_time_type")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
defaultEndTimeType, errObj := normalizeBaseShiftTimeType(stringOrDefault(req.Data.Attributes.DefaultEndTimeType, ""), "/data/attributes/default_end_time_type")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
defaultShiftStart, defaultShiftEnd, errObj := parseBaseDefaultShiftRange(
req.Data.Attributes.DefaultShiftStart,
req.Data.Attributes.DefaultShiftEnd,
req.Data.Attributes.DefaultShiftTime,
defaultStartTimeType,
defaultEndTimeType,
true,
"/data/attributes/default_shift_start",
"/data/attributes/default_shift_end",
"/data/attributes/default_shift_time",
)
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
operationalShiftTimes, errObj := parseBaseOperationalShiftTimes(req.Data.Attributes.OperationalShiftTimes, "/data/attributes/operational_shift_times")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
hemsEDCIDs, errObj := parseUUIDList(req.Data.Attributes.HEMSEDCContactIDs, "/data/attributes/hems_edc_contact_ids")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
medPaxIDs, errObj := parseUUIDList(req.Data.Attributes.MedPaxContactIDs, "/data/attributes/med_pax_contact_ids")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
pilotIDs, errObj := parseUUIDList(req.Data.Attributes.ResponsiblePilotContactIDs, "/data/attributes/responsible_pilot_contact_ids")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
modelID := uuidv7.MustBytes()
fotoAttachmentID := []byte(nil)
if req.Data.Attributes.FileUUID != nil {
resolved, handled := h.resolveBaseFotoAttachmentID(c, modelID, req.Data.Attributes.FileUUID)
if handled {
return true
}
fotoAttachmentID = resolved
}
res, err := svc.CreateDetailed(c.UserContext(), appservice.BaseCreateInput{
ID: modelID,
CategoryType: categoryType,
BaseName: baseName,
BaseAbbreviation: stringOrDefault(req.Data.Attributes.BaseAbbreviation, ""),
Address: stringOrDefault(req.Data.Attributes.Address, ""),
Latitude: float64OrDefault(req.Data.Attributes.Latitude, 0),
Longitude: float64OrDefault(req.Data.Attributes.Longitude, 0),
LandlineNumber: stringOrDefault(req.Data.Attributes.LandlineNumber, ""),
MobileNumber: stringOrDefault(req.Data.Attributes.MobileNumber, ""),
Email: stringOrDefault(req.Data.Attributes.Email, ""),
SortKey: req.Data.Attributes.SortKey,
SMSAlert: boolOrDefault(req.Data.Attributes.SMSAlert, false),
Checklist: boolOrDefault(req.Data.Attributes.Checklist, false),
LegTime: stringOrDefault(req.Data.Attributes.LegTime, ""),
DefaultStartTimeType: defaultStartTimeType,
DefaultEndTimeType: defaultEndTimeType,
DefaultShiftStart: defaultShiftStart,
DefaultShiftEnd: defaultShiftEnd,
UTC: stringOrDefault(req.Data.Attributes.UTC, ""),
Dry: boolOrDefault(req.Data.Attributes.Dry, false),
ControlCenter: boolOrDefault(req.Data.Attributes.ControlCenter, false),
DUL: boolOrDefault(req.Data.Attributes.DUL, false),
Notes: stringOrDefault(req.Data.Attributes.Notes, ""),
HEMSEDCContactIDs: hemsEDCIDs,
MedPaxContactIDs: medPaxIDs,
ResponsiblePilotContactIDs: pilotIDs,
OperationalShiftTimes: operationalShiftTimes,
IsActive: boolOrDefault(req.Data.Attributes.IsActive, true),
CreatedBy: createdBy,
UpdatedBy: updatedBy,
FotoAttachmentID: fotoAttachmentID,
})
if err != nil {
var writeErr *appservice.BaseWriteError
if errors.As(err, &writeErr) {
errObj := jsonapi.ErrorObject{
Status: strconv.Itoa(writeErr.Status),
Title: writeErr.Title,
Detail: writeErr.Detail,
}
if strings.TrimSpace(writeErr.Pointer) != "" {
errObj.Source = &jsonapi.ErrorSource{Pointer: writeErr.Pointer}
}
_ = writeBaseErrors(c, writeErr.Status, []jsonapi.ErrorObject{{
Status: errObj.Status, Title: errObj.Title, Detail: errObj.Detail, Source: errObj.Source,
}})
return true
}
_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: safeInternalDetail(),
}})
return true
}
if len(res.PreviousFotoAttachmentID) == 16 {
_ = syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, res.PreviousFotoAttachmentID, res.Row.FotoAttachmentID, actorUserID(c))
}
_ = response.Write(c, fiber.StatusCreated, h.baseResourceByCategory(c.UserContext(), res.Row, res.CategoryType))
return true
}
func (h *BaseHandler) updateBaseWithService(c *fiber.Ctx, svc baseDetailedWriter) bool {
uuidStr := c.Params("uuid")
id, err := uuidv7.ParseString(uuidStr)
if err != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "uuid is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"},
}})
return true
}
var req dto.BaseUpdateRequest
if err := c.BodyParser(&req); err != nil {
_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: safeInternalDetail(),
}})
return true
}
attrs := req.Data.Attributes
categoryType, errObj := resolveBaseCategoryType(c, attrs.BaseCategory, "/data/attributes/base_category")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
if errObj := validateOptionalBaseCoordinates(attrs.Latitude, attrs.Longitude, "/data/attributes/latitude", "/data/attributes/longitude"); errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
if attrs.BaseName == nil && attrs.BaseAbbreviation == nil && attrs.Address == nil && attrs.Latitude == nil && attrs.Longitude == nil && attrs.LandlineNumber == nil &&
attrs.MobileNumber == nil && attrs.Email == nil && attrs.SMSAlert == nil && attrs.Checklist == nil &&
!attrs.SortKey.Set &&
attrs.LegTime == nil && attrs.DefaultStartTimeType == nil && attrs.DefaultEndTimeType == nil &&
attrs.DefaultShiftStart == nil && attrs.DefaultShiftEnd == nil && attrs.DefaultShiftTime == nil && attrs.ShiftTime == nil &&
attrs.UTC == nil && attrs.FileUUID == nil && attrs.Dry == nil &&
attrs.ControlCenter == nil && attrs.DUL == nil && attrs.Notes == nil && attrs.IsActive == nil &&
attrs.HEMSEDCContactIDs == nil && attrs.MedPaxContactIDs == nil &&
attrs.ResponsiblePilotContactIDs == nil &&
attrs.OperationalShiftTimes == nil &&
attrs.CreatedBy == nil && attrs.UpdatedBy == nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "at least one attribute must be provided",
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"},
}})
return true
}
var defaultShiftStart *time.Time
var defaultShiftEnd *time.Time
var defaultStartTimeType *string
var defaultEndTimeType *string
if attrs.DefaultStartTimeType != nil || attrs.DefaultEndTimeType != nil || attrs.DefaultShiftStart != nil || attrs.DefaultShiftEnd != nil || attrs.DefaultShiftTime != nil {
startType, errObj := normalizeBaseShiftTimeType(stringOrDefault(attrs.DefaultStartTimeType, ""), "/data/attributes/default_start_time_type")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
endType, errObj := normalizeBaseShiftTimeType(stringOrDefault(attrs.DefaultEndTimeType, ""), "/data/attributes/default_end_time_type")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
parsedStart, parsedEnd, errObj := parseBaseDefaultShiftRange(
attrs.DefaultShiftStart,
attrs.DefaultShiftEnd,
attrs.DefaultShiftTime,
startType,
endType,
false,
"/data/attributes/default_shift_start",
"/data/attributes/default_shift_end",
"/data/attributes/default_shift_time",
)
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
if attrs.DefaultShiftStart != nil || attrs.DefaultShiftTime != nil {
defaultShiftStart = parsedStart
}
if attrs.DefaultShiftEnd != nil || attrs.DefaultShiftTime != nil {
defaultShiftEnd = parsedEnd
}
if attrs.DefaultStartTimeType != nil {
defaultStartTimeType = &startType
}
if attrs.DefaultEndTimeType != nil {
defaultEndTimeType = &endType
}
}
if attrs.ShiftTime != nil && (strings.TrimSpace(attrs.ShiftTime.Start) != "" || strings.TrimSpace(attrs.ShiftTime.End) != "") {
if strings.TrimSpace(attrs.ShiftTime.Start) != "" {
start, errObj := parseOptionalClock(&attrs.ShiftTime.Start, "/data/attributes/shift_time/start")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
defaultShiftStart = start
}
if strings.TrimSpace(attrs.ShiftTime.End) != "" {
end, errObj := parseOptionalClock(&attrs.ShiftTime.End, "/data/attributes/shift_time/end")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
defaultShiftEnd = end
}
}
operationalShiftTimesSet := attrs.OperationalShiftTimes != nil
operationalShiftTimes, errObj := parseBaseOperationalShiftTimes(attrs.OperationalShiftTimes, "/data/attributes/operational_shift_times")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
var fotoAttachmentID []byte
fotoAttachmentSet := false
if attrs.FileUUID != nil {
fotoAttachmentSet = true
if hasBlankOptionalString(attrs.FileUUID) {
fotoAttachmentID = nil
} else {
resolved, handled := h.resolveExistingBaseFotoAttachmentOrFileID(c, id, attrs.FileUUID)
if handled {
return true
}
fotoAttachmentID = resolved
}
}
hemsEDCSet := attrs.HEMSEDCContactIDs != nil
medPaxSet := attrs.MedPaxContactIDs != nil
pilotSet := attrs.ResponsiblePilotContactIDs != nil
var hemsEDCIDs [][]byte
var medPaxIDs [][]byte
var pilotIDs [][]byte
if hemsEDCSet {
var errObj *jsonapi.ErrorObject
hemsEDCIDs, errObj = parseUUIDList(*attrs.HEMSEDCContactIDs, "/data/attributes/hems_edc_contact_ids")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
}
if medPaxSet {
var errObj *jsonapi.ErrorObject
medPaxIDs, errObj = parseUUIDList(*attrs.MedPaxContactIDs, "/data/attributes/med_pax_contact_ids")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
}
if pilotSet {
var errObj *jsonapi.ErrorObject
pilotIDs, errObj = parseUUIDList(*attrs.ResponsiblePilotContactIDs, "/data/attributes/responsible_pilot_contact_ids")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
}
var sortKeyValue *int
sortKeyValid := false
if attrs.SortKey.Set {
sortKeyVal := attrs.SortKey.Value
sortKeyValue = &sortKeyVal
sortKeyValid = attrs.SortKey.Valid
}
createdBySet := attrs.CreatedBy != nil
updatedBySet := attrs.UpdatedBy != nil
var createdBy []byte
var updatedBy []byte
if createdBySet {
parsed, errObj := parseUUIDOrClear(*attrs.CreatedBy, "/data/attributes/created_by")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
createdBy = parsed
}
if updatedBySet {
parsed, errObj := parseUUIDOrClear(*attrs.UpdatedBy, "/data/attributes/updated_by")
if errObj != nil {
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
return true
}
updatedBy = parsed
}
res, err := svc.UpdateDetailed(c.UserContext(), appservice.BaseUpdateInput{
ID: id,
CategoryType: categoryType,
BaseName: attrs.BaseName,
BaseAbbreviation: attrs.BaseAbbreviation,
Address: attrs.Address,
Latitude: attrs.Latitude,
Longitude: attrs.Longitude,
LandlineNumber: attrs.LandlineNumber,
MobileNumber: attrs.MobileNumber,
Email: attrs.Email,
SortKeySet: attrs.SortKey.Set,
SortKeyValid: sortKeyValid,
SortKeyValue: sortKeyValue,
SMSAlert: attrs.SMSAlert,
Checklist: attrs.Checklist,
LegTime: attrs.LegTime,
DefaultStartTimeType: defaultStartTimeType,
DefaultEndTimeType: defaultEndTimeType,
DefaultShiftStart: defaultShiftStart,
DefaultShiftEnd: defaultShiftEnd,
UTC: attrs.UTC,
Dry: attrs.Dry,
ControlCenter: attrs.ControlCenter,
DUL: attrs.DUL,
Notes: attrs.Notes,
HEMSEDCContactIDs: hemsEDCIDs,
HEMSEDCContactIDsSet: hemsEDCSet,
MedPaxContactIDs: medPaxIDs,
MedPaxContactIDsSet: medPaxSet,
ResponsiblePilotContactIDs: pilotIDs,
ResponsiblePilotContactIDsSet: pilotSet,
OperationalShiftTimes: operationalShiftTimes,
OperationalShiftTimesSet: operationalShiftTimesSet,
IsActive: attrs.IsActive,
CreatedBy: createdBy,
CreatedBySet: createdBySet,
UpdatedBy: updatedBy,
UpdatedBySet: updatedBySet,
FotoAttachmentID: fotoAttachmentID,
FotoAttachmentSet: fotoAttachmentSet,
})
if err != nil {
var writeErr *appservice.BaseWriteError
if errors.As(err, &writeErr) {
errObj := jsonapi.ErrorObject{
Status: strconv.Itoa(writeErr.Status),
Title: writeErr.Title,
Detail: writeErr.Detail,
}
if strings.TrimSpace(writeErr.Pointer) != "" {
errObj.Source = &jsonapi.ErrorSource{Pointer: writeErr.Pointer}
}
_ = writeBaseErrors(c, writeErr.Status, []jsonapi.ErrorObject{errObj})
return true
}
_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Update failed",
Detail: safeInternalDetail(),
}})
return true
}
if err := syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, res.PreviousFotoAttachmentID, res.Row.FotoAttachmentID, actorUserID(c)); err != nil {
_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Update failed",
Detail: safeInternalDetail(),
}})
return true
}
_ = response.Write(c, fiber.StatusOK, h.baseResourceByCategory(c.UserContext(), res.Row, res.CategoryType))
return true
}
// CreateBase godoc
// @Summary Create base
// @Tags Bases
// @Accept json
// @Produce json
// @Description Example request:
// @Description {"data":{"type":"base","attributes":{"base":"Lude Testing 2","base_abbreviation":"LOIG","base_category":"regular","address":"Lude Airfield","latitude":47.3769,"longitude":8.5417,"sortkey":1,"sms_alert":false,"checklist":false,"leg_time":"00:20","default_start_time_type":"FIXED","default_end_time_type":"FIXED","default_shift_start":"06:00","default_shift_end":"21:00","utc":"0","notes":"Main base","operational_shift_times":[{"date_start":"2026-06-15","date_end":"2026-06-15","start_time_type":"BMCT","end_time_type":"ECET"}]}}}
// @Param request body dto.BaseCreateRequest true "JSON:API base create request"
// @Success 201 {object} dto.BaseResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 400 {object} dto.ErrorResponse
// @Router /api/v1/bases/create [post]
func (h *BaseHandler) CreateBase(c *fiber.Ctx) error {
svc, ok := h.svc.(baseDetailedWriter)
if !ok {
return writeBaseErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
Status: "500",
Title: "Create failed",
Detail: "base service is not configured",
}})
}
if h.createBaseWithService(c, svc) {
return nil
}
var req dto.BaseCreateRequest
if err := c.BodyParser(&req); err != nil {
return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: safeInternalDetail(),
}})
}
categoryType, errObj := resolveBaseCategoryType(c, req.Data.Attributes.BaseCategory, "/data/attributes/base_category")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
if errObj := validateBaseCoordinates(req.Data.Attributes.Latitude, req.Data.Attributes.Longitude, "/data/attributes/latitude", "/data/attributes/longitude"); errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
baseName := strings.TrimSpace(req.Data.Attributes.BaseName)
if baseName == "" {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "base is required",
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base"},
}})
}
createdBy, errObj := parseOptionalUUID(req.Data.Attributes.CreatedBy, "/data/attributes/created_by")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
updatedBy, errObj := parseOptionalUUID(req.Data.Attributes.UpdatedBy, "/data/attributes/updated_by")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
defaultStartTimeType, errObj := normalizeBaseShiftTimeType(stringOrDefault(req.Data.Attributes.DefaultStartTimeType, ""), "/data/attributes/default_start_time_type")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
defaultEndTimeType, errObj := normalizeBaseShiftTimeType(stringOrDefault(req.Data.Attributes.DefaultEndTimeType, ""), "/data/attributes/default_end_time_type")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
defaultShiftStart, defaultShiftEnd, errObj := parseBaseDefaultShiftRange(
req.Data.Attributes.DefaultShiftStart,
req.Data.Attributes.DefaultShiftEnd,
req.Data.Attributes.DefaultShiftTime,
defaultStartTimeType,
defaultEndTimeType,
true,
"/data/attributes/default_shift_start",
"/data/attributes/default_shift_end",
"/data/attributes/default_shift_time",
)
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
row := &base.Base{ID: uuidv7.MustBytes()}
*row = *dto.MapBaseCreateAttributesToDomain(req.Data.Attributes)
row.ID = uuidv7.MustBytes()
row.BaseName = baseName
row.DefaultStartTimeType = defaultStartTimeType
row.DefaultEndTimeType = defaultEndTimeType
row.DefaultShiftStart = clockStringOrEmpty(defaultShiftStart)
row.DefaultShiftEnd = clockStringOrEmpty(defaultShiftEnd)
if req.Data.Attributes.FileUUID != nil {
fotoAttachmentID, resolveErr := h.resolveBaseFotoAttachmentID(c, row.ID, req.Data.Attributes.FileUUID)
if resolveErr {
return nil
}
row.FotoAttachmentID = fotoAttachmentID
}
hemsEDCIDs, errObj := parseUUIDList(req.Data.Attributes.HEMSEDCContactIDs, "/data/attributes/hems_edc_contact_ids")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
medPaxIDs, errObj := parseUUIDList(req.Data.Attributes.MedPaxContactIDs, "/data/attributes/med_pax_contact_ids")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
pilotIDs, errObj := parseUUIDList(req.Data.Attributes.ResponsiblePilotContactIDs, "/data/attributes/responsible_pilot_contact_ids")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
row.HEMSEDCContactIDs = hemsEDCIDs
row.MedPaxContactIDs = medPaxIDs
row.ResponsiblePilotContactIDs = pilotIDs
row.CreatedBy = createdBy
row.UpdatedBy = updatedBy
if err := h.svc.CreateBase(c.UserContext(), row, categoryType); err != nil {
return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: safeInternalDetail(),
}})
}
if len(row.FotoAttachmentID) == 16 {
if err := syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, nil, row.FotoAttachmentID, actorUserID(c)); err != nil {
return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Create failed",
Detail: safeInternalDetail(),
}})
}
}
created, err := h.svc.GetBaseByID(c.UserContext(), row.ID, categoryType)
if err == nil && created != nil {
return response.Write(c, fiber.StatusCreated, h.baseResourceByCategory(c.UserContext(), created, categoryType))
}
return response.Write(c, fiber.StatusCreated, h.baseResourceByCategory(c.UserContext(), row, categoryType))
}
// UpdateBase godoc
// @Summary Update base
// @Tags Bases
// @Accept json
// @Produce json
// @Param uuid path string true "Base UUID (UUIDv7)"
// @Description Example request:
// @Description {"data":{"type":"hems_base","id":"<uuid>","attributes":{"base":"Lude Testing 2","base_abbreviation":"LOIG","base_category":"regular","address":"Lude Airfield","latitude":47.3769,"longitude":8.5417,"sortkey":1,"sms_alert":false,"checklist":false,"leg_time":"00:20","default_start_time_type":"FIXED","default_end_time_type":"FIXED","default_shift_start":"06:00","default_shift_end":"21:00","utc":"0","notes":"Main base","operational_shift_times":[{"date_start":"2026-06-15","date_end":"2026-06-15","start_time_type":"BMCT","end_time_type":"ECET"}]}}}
// @Param request body dto.BaseUpdateRequest true "JSON:API base update request"
// @Success 200 {object} dto.BaseResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/bases/update/{uuid} [patch]
func (h *BaseHandler) UpdateBase(c *fiber.Ctx) error {
svc, ok := h.svc.(baseDetailedWriter)
if !ok {
return writeBaseErrors(c, fiber.StatusInternalServerError, []jsonapi.ErrorObject{{
Status: "500",
Title: "Update failed",
Detail: "base service is not configured",
}})
}
if h.updateBaseWithService(c, svc) {
return nil
}
uuidStr := c.Params("uuid")
id, err := uuidv7.ParseString(uuidStr)
if err != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "uuid is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"},
}})
}
var req dto.BaseUpdateRequest
rawBody := append([]byte(nil), c.Body()...)
if err := c.BodyParser(&req); err != nil {
return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Invalid JSON",
Detail: safeInternalDetail(),
}})
}
attrs := req.Data.Attributes
if attrs.ShiftTime == nil {
attrs.ShiftTime = extractBaseShiftTimeFromUpdateBody(rawBody)
}
categoryType, errObj := resolveBaseCategoryType(c, attrs.BaseCategory, "/data/attributes/base_category")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
if errObj := validateOptionalBaseCoordinates(attrs.Latitude, attrs.Longitude, "/data/attributes/latitude", "/data/attributes/longitude"); errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
if attrs.BaseName == nil && attrs.BaseAbbreviation == nil && attrs.Address == nil && attrs.LandlineNumber == nil &&
attrs.MobileNumber == nil && attrs.Email == nil && attrs.SMSAlert == nil && attrs.Checklist == nil &&
!attrs.SortKey.Set &&
attrs.LegTime == nil && attrs.DefaultStartTimeType == nil && attrs.DefaultEndTimeType == nil &&
attrs.DefaultShiftStart == nil && attrs.DefaultShiftEnd == nil && attrs.DefaultShiftTime == nil && attrs.ShiftTime == nil &&
attrs.UTC == nil && attrs.FileUUID == nil && attrs.Dry == nil &&
attrs.ControlCenter == nil && attrs.DUL == nil && attrs.Notes == nil && attrs.IsActive == nil &&
attrs.HEMSEDCContactIDs == nil && attrs.MedPaxContactIDs == nil &&
attrs.ResponsiblePilotContactIDs == nil &&
attrs.CreatedBy == nil && attrs.UpdatedBy == nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "at least one attribute must be provided",
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"},
}})
}
existing, err := h.svc.GetBaseByID(c.UserContext(), id, categoryType)
if err != nil || existing == nil {
return writeBaseErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: categoryNotFoundDetail(categoryType),
}})
}
prevFotoAttachmentID := append([]byte(nil), existing.FotoAttachmentID...)
if attrs.BaseName != nil {
v := strings.TrimSpace(*attrs.BaseName)
if v == "" {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "base cannot be empty",
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/base"},
}})
}
existing.BaseName = v
}
dto.ApplyBaseUpdateAttributesToDomain(existing, attrs)
if attrs.DefaultStartTimeType != nil {
parsed, errObj := normalizeBaseShiftTimeType(*attrs.DefaultStartTimeType, "/data/attributes/default_start_time_type")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
existing.DefaultStartTimeType = parsed
}
if attrs.DefaultEndTimeType != nil {
parsed, errObj := normalizeBaseShiftTimeType(*attrs.DefaultEndTimeType, "/data/attributes/default_end_time_type")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
existing.DefaultEndTimeType = parsed
}
if attrs.DefaultShiftStart != nil || attrs.DefaultShiftEnd != nil || attrs.DefaultShiftTime != nil {
parsedStart, parsedEnd, errObj := parseBaseDefaultShiftRange(
attrs.DefaultShiftStart,
attrs.DefaultShiftEnd,
attrs.DefaultShiftTime,
normalizeBaseShiftTimeTypeOrFixed(existing.DefaultStartTimeType),
normalizeBaseShiftTimeTypeOrFixed(existing.DefaultEndTimeType),
false,
"/data/attributes/default_shift_start",
"/data/attributes/default_shift_end",
"/data/attributes/default_shift_time",
)
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
if attrs.DefaultShiftStart != nil || attrs.DefaultShiftTime != nil {
existing.DefaultShiftStart = clockStringOrEmpty(parsedStart)
}
if attrs.DefaultShiftEnd != nil || attrs.DefaultShiftTime != nil {
existing.DefaultShiftEnd = clockStringOrEmpty(parsedEnd)
}
}
if normalizeBaseShiftTimeTypeOrFixed(existing.DefaultStartTimeType) != base.ShiftTimeTypeFixed {
existing.DefaultShiftStart = ""
}
if normalizeBaseShiftTimeTypeOrFixed(existing.DefaultEndTimeType) != base.ShiftTimeTypeFixed {
existing.DefaultShiftEnd = ""
}
if attrs.FileUUID != nil {
if hasBlankOptionalString(attrs.FileUUID) {
existing.FotoAttachmentID = nil
} else {
fotoAttachmentID, resolveErr := h.resolveExistingBaseFotoAttachmentOrFileID(c, existing.ID, attrs.FileUUID)
if resolveErr {
return nil
}
existing.FotoAttachmentID = fotoAttachmentID
}
}
if attrs.HEMSEDCContactIDs != nil {
parsed, errObj := parseUUIDList(*attrs.HEMSEDCContactIDs, "/data/attributes/hems_edc_contact_ids")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
existing.HEMSEDCContactIDs = parsed
}
if attrs.MedPaxContactIDs != nil {
parsed, errObj := parseUUIDList(*attrs.MedPaxContactIDs, "/data/attributes/med_pax_contact_ids")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
existing.MedPaxContactIDs = parsed
}
if attrs.ResponsiblePilotContactIDs != nil {
parsed, errObj := parseUUIDList(*attrs.ResponsiblePilotContactIDs, "/data/attributes/responsible_pilot_contact_ids")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
existing.ResponsiblePilotContactIDs = parsed
}
if attrs.CreatedBy != nil {
parsed, errObj := parseUUIDOrClear(*attrs.CreatedBy, "/data/attributes/created_by")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
existing.CreatedBy = parsed
}
if attrs.UpdatedBy != nil {
parsed, errObj := parseUUIDOrClear(*attrs.UpdatedBy, "/data/attributes/updated_by")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
existing.UpdatedBy = parsed
}
// Convert shift_time object to individual fields (handler responsibility for HTTP format translation)
if attrs.ShiftTime != nil && (attrs.ShiftTime.Start != "" || attrs.ShiftTime.End != "") {
if attrs.ShiftTime.Start != "" {
start, errObj := parseOptionalClock(&attrs.ShiftTime.Start, "/data/attributes/shift_time/start")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
existing.DefaultShiftStart = clockStringOrEmpty(start)
}
if attrs.ShiftTime.End != "" {
end, errObj := parseOptionalClock(&attrs.ShiftTime.End, "/data/attributes/shift_time/end")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
existing.DefaultShiftEnd = clockStringOrEmpty(end)
}
}
if err := h.svc.UpdateBase(c.UserContext(), existing, categoryType); err != nil {
return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Update failed",
Detail: safeInternalDetail(),
}})
}
if err := syncAttachmentLifecycleSwap(c.UserContext(), h.fileManager, prevFotoAttachmentID, existing.FotoAttachmentID, actorUserID(c)); err != nil {
return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Update failed",
Detail: safeInternalDetail(),
}})
}
updated, err := h.svc.GetBaseByID(c.UserContext(), existing.ID, categoryType)
if err == nil && updated != nil {
if attrs.ShiftTime != nil && (attrs.ShiftTime.Start != "" || attrs.ShiftTime.End != "") {
updated.DefaultShiftStart = existing.DefaultShiftStart
updated.DefaultShiftEnd = existing.DefaultShiftEnd
}
if attrs.OperationalShiftTimes != nil {
updated.OperationalShiftTimes = existing.OperationalShiftTimes
}
return response.Write(c, fiber.StatusOK, h.baseResourceByCategory(c.UserContext(), updated, categoryType))
}
return response.Write(c, fiber.StatusOK, h.baseResourceByCategory(c.UserContext(), existing, categoryType))
}
// DeleteBase godoc
// @Summary Delete base
// @Tags Bases
// @Produce json
// @Param uuid path string true "Base UUID (UUIDv7)"
// @Success 200 {object} dto.BaseDeleteResponse
// @Failure 422 {object} dto.ErrorResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/bases/delete/{uuid} [delete]
func (h *BaseHandler) DeleteBase(c *fiber.Ctx) error {
categoryType, errObj := parseBaseCategoryTypeQuery(c)
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
uuidStr := c.Params("uuid")
id, err := uuidv7.ParseString(uuidStr)
if err != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "uuid is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"},
}})
}
row, err := h.svc.DeleteBase(c.UserContext(), id, categoryType)
if err != nil {
if _, ok := apperrors.AsDeleteConflict(err); ok {
def := apperrorsx.ErrBaseRelationDelete
return jsonapi.WriteErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{
Status: "409",
Code: def.Code,
ErrorCode: def.ErrorCode,
Title: def.Title,
Detail: err.Error(),
}})
}
return writeDeleteError(c, err)
}
if row == nil {
return writeBaseErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: "base not found",
}})
}
if row == nil {
return writeBaseErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: categoryNotFoundDetail(categoryType),
}})
}
if row != nil && len(row.FotoAttachmentID) == 16 {
if err := syncAttachmentLifecycleClear(c.UserContext(), h.fileManager, row.FotoAttachmentID, actorUserID(c)); err != nil {
return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Delete failed",
Detail: safeInternalDetail(),
}})
}
}
deleteType := "base_delete"
if categoryType == base.CategoryKeyHEMS {
deleteType = "hems_base_delete"
}
idStr, _ := uuidv7.BytesToString(id)
if row == nil {
// fallback minimal response when no row is available
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: deleteType,
ID: idStr,
Attributes: map[string]any{"deleted": true},
})
}
// minimal attributes required for delete response per request
attrs := map[string]any{
"base": row.BaseName,
"base_abbreviation": row.BaseAbbreviation,
"base_category": baseCategoryFromRowOrType(row, categoryType),
"created_at": row.CreatedAt.UTC().Format(time.RFC3339),
"created_by": bytesUUIDToString(row.CreatedBy),
"updated_at": row.UpdatedAt.UTC().Format(time.RFC3339),
"updated_by": bytesUUIDToString(row.UpdatedBy),
"deleted_at": timeStringOrEmpty(row.DeletedAt),
"deleted_by": bytesUUIDToString(row.DeletedBy),
}
return response.Write(c, fiber.StatusOK, jsonapi.Resource{
Type: deleteType,
ID: idStr,
Attributes: attrs,
})
}
// GetBaseByID godoc
// @Summary Get base by ID
// @Tags Bases
// @Produce json
// @Param uuid path string true "Base UUID (UUIDv7)"
// @Success 200 {object} dto.BaseResponse
// @Failure 404 {object} dto.ErrorResponse
// @Router /api/v1/bases/get/{uuid} [get]
func (h *BaseHandler) GetBase(c *fiber.Ctx) error {
categoryType, errObj := parseBaseCategoryTypeQuery(c)
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
uuidStr := c.Params("uuid")
id, err := uuidv7.ParseString(uuidStr)
if err != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: "uuid is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: "/path/uuid"},
}})
}
row, err := h.svc.GetBaseByID(c.UserContext(), id, categoryType)
if err != nil || row == nil {
return writeBaseErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{
Status: "404",
Title: "Not found",
Detail: categoryNotFoundDetail(categoryType),
}})
}
return response.Write(c, fiber.StatusOK, h.baseResourceByCategory(c.UserContext(), row, categoryType))
}
// ListBases godoc
// @Summary List bases
// @Description JSON:API list with filtering and sorting. This endpoint returns all bases.
// @Tags Bases
// @Produce json
// @Param category_type query string false "Base category" Enums(regular,hems)
// @Param base_category query string false "Alias filter for base category" Enums(regular,hems)
// @Param search query string false "Search by base/base_abbreviation/address/email/phone"
// @Param dul query boolean false "Filter by DUL flag (true/false)"
// @Param page query int false "Ignored"
// @Param size query int false "Ignored"
// @Param limit query int false "Ignored"
// @Param sort query string false "Sort order"
// @Success 200 {object} dto.BaseListResponse
// @Router /api/v1/bases/get-all [get]
func (h *BaseHandler) ListBases(c *fiber.Ctx) error {
categoryType, errObj := parseOptionalBaseCategoryTypeQuery(c)
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
dulFilter, errObj := parseOptionalBoolQueryParam(c, "dul")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
filter := c.Query("search")
sort := c.Query("sort")
rows, total, err := h.svc.ListBases(c.UserContext(), filter, sort, 0, 0, categoryType, dulFilter)
if err != nil {
return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "List failed",
Detail: safeInternalDetail(),
}})
}
data := make([]dto.BaseResource, 0, len(rows))
for i := range rows {
data = append(data, h.baseResourceByCategory(c.UserContext(), &rows[i], categoryType))
}
meta := map[string]any{
"total": total,
}
return response.WriteWithMeta(c, fiber.StatusOK, data, meta)
}
// ListBasesDatatable godoc
// @Summary List bases (datatable)
// @Tags Bases
// @Produce json
// @Param category_type query string false "Base category" Enums(regular,hems)
// @Param base_category query string false "Alias filter for base category" Enums(regular,hems)
// @Param page query int false "Page number (default 1)"
// @Param size query int false "Page size (default 20, max 100)"
// @Param limit query int false "Page size override (same as size)"
// @Param draw query int false "Draw counter (optional)"
// @Param search query string false "Search term"
// @Param dul query boolean false "Filter by DUL flag (true/false)"
// @Success 200 {object} dto.BaseDataTableResponse
// @Router /api/v1/bases/get-all/dt [get]
func (h *BaseHandler) ListBasesDatatable(c *fiber.Ctx) error {
categoryType, errObj := parseOptionalBaseCategoryTypeQuery(c)
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
dulFilter, errObj := parseOptionalBoolQueryParam(c, "dul")
if errObj != nil {
return writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{*errObj})
}
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)
search := c.Query("search")
rows, total, err := h.svc.ListBases(c.UserContext(), search, "", length, start, categoryType, dulFilter)
if err != nil {
return writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "List failed",
Detail: safeInternalDetail(),
}})
}
data := make([]dto.BaseResource, 0, len(rows))
for i := range rows {
data = append(data, h.baseResourceByCategory(c.UserContext(), &rows[i], categoryType))
}
meta := map[string]any{
"draw": draw,
"records_total": total,
"records_filtered": total,
}
return response.WriteWithMeta(c, fiber.StatusOK, data, meta)
}
func (h *BaseHandler) baseResource(ctx context.Context, row *base.Base, categoryType string) dto.BaseResource {
baseRow := row
id := ""
defaultShiftStart := ""
defaultShiftEnd := ""
defaultStartTimeType := base.ShiftTimeTypeFixed
defaultEndTimeType := base.ShiftTimeTypeFixed
if baseRow != nil {
id, _ = uuidv7.BytesToString(baseRow.ID)
defaultStartTimeType = normalizeBaseShiftTimeTypeOrFixed(baseRow.DefaultStartTimeType)
defaultEndTimeType = normalizeBaseShiftTimeTypeOrFixed(baseRow.DefaultEndTimeType)
defaultShiftStart = dto.DisplayBaseShiftValue(defaultStartTimeType, baseRow.DefaultShiftStart)
defaultShiftEnd = dto.DisplayBaseShiftValue(defaultEndTimeType, baseRow.DefaultShiftEnd)
}
baseCategory := baseCategoryFromRowOrType(baseRow, categoryType)
resourceType := "base"
if baseCategory == base.CategoryKeyHEMS {
resourceType = "hems_base"
}
var foto *dto.BaseFoto
fotoUUID := ""
if baseRow != nil {
fotoUUID = idString(baseRow.FotoAttachmentID)
}
fotoDownloadURL := ""
fotoThumbnailURL := ""
var fotoOriginalSizeBytes *int64
if baseRow != nil && baseRow.FotoAttachment != nil && baseRow.FotoAttachment.File != nil {
asset := resolveFileManagerDownloadURLs(ctx, h.storage, baseRow.FotoAttachment.File)
fotoDownloadURL = strings.TrimSpace(asset.OriginalURL)
fotoThumbnailURL = strings.TrimSpace(asset.ThumbnailURL)
fotoOriginalSizeBytes = int64Ptr(asset.OriginalSizeBytes)
}
if fotoUUID != "" || fotoDownloadURL != "" || fotoThumbnailURL != "" || fotoOriginalSizeBytes != nil {
foto = &dto.BaseFoto{
UUID: fotoUUID,
DownloadURL: fotoDownloadURL,
ThumbnailDownloadURL: fotoThumbnailURL,
OriginalSizeBytes: fotoOriginalSizeBytes,
}
}
var hemsEDCIDs []string
var hemsEDCs []dto.BaseContact
var medPaxIDs []string
var medPax []dto.BaseContact
var pilotIDs []string
var pilots []dto.BaseContact
if baseRow != nil {
hemsEDCIDs = uuidListToStrings(baseRow.HEMSEDCContactIDs)
hemsEDCs = baseContactResources(baseRow.HEMSEDCs)
medPaxIDs = uuidListToStrings(baseRow.MedPaxContactIDs)
medPax = baseContactResources(baseRow.MedPax)
pilotIDs = uuidListToStrings(baseRow.ResponsiblePilotContactIDs)
pilots = baseContactResources(baseRow.ResponsiblePilots)
}
operationalShiftTimes := []dto.BaseOperationalShiftTime(nil)
if baseRow != nil {
operationalShiftTimes = dto.MapBaseOperationalShiftTimesToDTO(baseRow.OperationalShiftTimes)
}
return dto.MapBaseToResource(baseRow, dto.BaseResponseMappingInput{
ResourceType: resourceType,
BaseCategory: baseCategory,
ID: id,
DefaultStartTimeType: defaultStartTimeType,
DefaultEndTimeType: defaultEndTimeType,
ShiftTime: dto.BaseShiftTime{Start: defaultShiftStart, End: defaultShiftEnd},
Foto: foto,
Latitude: float64FromBase(baseRow),
Longitude: float64FromBaseLongitude(baseRow),
HEMSEDCContactIDs: hemsEDCIDs,
HEMSEDCs: hemsEDCs,
MedPaxContactIDs: medPaxIDs,
MedPax: medPax,
ResponsiblePilotContactIDs: pilotIDs,
ResponsiblePilots: pilots,
OperationalShiftTimes: operationalShiftTimes,
})
}
func float64FromBase(row *base.Base) float64 {
if row == nil {
return 0
}
return row.Latitude
}
func float64FromBaseLongitude(row *base.Base) float64 {
if row == nil {
return 0
}
return row.Longitude
}
func cloneIntPointerBase(v *int) *int {
if v == nil {
return nil
}
copied := *v
return &copied
}
func preferStringPtr(primary, fallback *string) *string {
if primary != nil {
return primary
}
return fallback
}
func parseUUIDList(values []string, pointer string) ([][]byte, *jsonapi.ErrorObject) {
out := make([][]byte, 0, len(values))
seen := map[string]struct{}{}
for i := range values {
v := strings.TrimSpace(values[i])
if v == "" {
continue
}
id, err := uuidv7.ParseString(v)
if err != nil {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "value is invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
key := string(id)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, id)
}
return out, nil
}
func uuidListToStrings(values [][]byte) []string {
if len(values) == 0 {
return nil
}
out := make([]string, 0, len(values))
for i := range values {
id, err := uuidv7.BytesToString(values[i])
if err != nil {
continue
}
out = append(out, id)
}
return out
}
func baseContactResources(values []base.BaseContactPerson) []dto.BaseContact {
if len(values) == 0 {
return nil
}
out := make([]dto.BaseContact, 0, len(values))
for i := range values {
id, err := uuidv7.BytesToString(values[i].ContactID)
if err != nil || strings.TrimSpace(id) == "" {
continue
}
out = append(out, dto.BaseContact{
ID: id,
FirstName: values[i].FirstName,
LastName: values[i].LastName,
})
}
if len(out) == 0 {
return nil
}
return out
}
func (h *BaseHandler) baseResourceByCategory(ctx context.Context, row *base.Base, categoryType string) dto.BaseResource {
return h.baseResource(ctx, row, categoryType)
}
func (h *BaseHandler) resolveBaseFotoAttachmentID(
c *fiber.Ctx,
baseID []byte,
rawFileUUID *string,
) ([]byte, bool) {
return h.resolveAttachmentIDFromInput(c, attachmentresolver.Params{
FileManager: h.fileManager,
RawFileID: rawFileUUID,
FileField: "file_uuid",
RequireValue: false,
RefType: basePhotoAttachmentRefType,
RefID: idString(baseID),
Category: ptrString(basePhotoAttachmentCategory),
IsPrimary: true,
ActorID: actorUserID(c),
ValidateAttachmentExists: false,
}, "Update failed")
}
func (h *BaseHandler) resolveExistingBaseFotoAttachmentOrFileID(
c *fiber.Ctx,
baseID []byte,
rawUUID *string,
) ([]byte, bool) {
if hasNonEmptyOptionalString(rawUUID) && h.fileManager != nil {
candidateID, err := uuidv7.ParseString(strings.TrimSpace(*rawUUID))
if err == nil {
attachment, getErr := h.fileManager.GetAttachmentByID(c.UserContext(), candidateID)
if getErr != nil {
_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: "Update failed",
Detail: getErr.Error(),
}})
return nil, true
}
if attachment != nil {
return candidateID, false
}
}
}
return h.resolveBaseFotoAttachmentID(c, baseID, rawUUID)
}
func (h *BaseHandler) resolveAttachmentIDFromInput(
c *fiber.Ctx,
params attachmentresolver.Params,
errorTitle string,
) ([]byte, bool) {
attachmentID, err := attachmentresolver.Resolve(c.UserContext(), params)
if err == nil {
return attachmentID, false
}
var resolveErr *attachmentresolver.ResolveError
if errors.As(err, &resolveErr) {
switch resolveErr.Code {
case attachmentresolver.ErrorInvalidAttachmentUUID:
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: fmt.Sprintf("%s is invalid UUID", fieldOrDefault(resolveErr.AttachmentField, "attachment_id")),
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.AttachmentField, "attachment_id")},
}})
return nil, true
case attachmentresolver.ErrorInvalidFileUUID:
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: fmt.Sprintf("%s is invalid UUID", fieldOrDefault(resolveErr.FileField, "file_id")),
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.FileField, "file_id")},
}})
return nil, true
case attachmentresolver.ErrorAttachmentNotFound:
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: fmt.Sprintf("%s not found", fieldOrDefault(resolveErr.AttachmentField, "attachment_id")),
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.AttachmentField, "attachment_id")},
}})
return nil, true
case attachmentresolver.ErrorRequireValue:
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: fmt.Sprintf("%s or %s is required", fieldOrDefault(resolveErr.AttachmentField, "attachment_id"), fieldOrDefault(resolveErr.FileField, "file_id")),
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes"},
}})
return nil, true
case attachmentresolver.ErrorCannotResolveFromFile:
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: fmt.Sprintf("cannot resolve attachment from %s", fieldOrDefault(resolveErr.FileField, "file_id")),
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.FileField, "file_id")},
}})
return nil, true
case attachmentresolver.ErrorFileNotReadyForAttachment:
_ = writeBaseErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{
Status: "422",
Title: "Validation error",
Detail: fmt.Sprintf("%s is not ready yet; wait for file.updated SSE event", fieldOrDefault(resolveErr.FileField, "file_id")),
Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/" + fieldOrDefault(resolveErr.FileField, "file_id")},
}})
return nil, true
case attachmentresolver.ErrorFileManagerUnavailable:
_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: errorTitle,
Detail: resolveErr.Error(),
}})
return nil, true
}
}
_ = writeBaseErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{
Status: "400",
Title: errorTitle,
Detail: safeInternalDetail(),
}})
return nil, true
}
func parseBaseShiftRange(
startRaw *string,
endRaw *string,
legacyRaw *string,
startPointer string,
endPointer string,
legacyPointer string,
) (*time.Time, *time.Time, *jsonapi.ErrorObject) {
start, errObj := parseOptionalClock(startRaw, startPointer)
if errObj != nil {
return nil, nil, errObj
}
end, errObj := parseOptionalClock(endRaw, endPointer)
if errObj != nil {
return nil, nil, errObj
}
if legacyRaw == nil {
return start, end, nil
}
legacy := strings.TrimSpace(*legacyRaw)
if legacy == "" {
return start, end, nil
}
legacyStartRaw, legacyEndRaw := extractBaseShiftRangeTokens(legacy)
if legacyStartRaw == "" && legacyEndRaw == "" {
return nil, nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "default_shift_time must contain HH:MM or HH:MM:SS range",
Source: &jsonapi.ErrorSource{Pointer: legacyPointer},
}
}
if startRaw == nil && legacyStartRaw != "" {
start, errObj = parseOptionalClock(&legacyStartRaw, legacyPointer)
if errObj != nil {
return nil, nil, errObj
}
}
if endRaw == nil && legacyEndRaw != "" {
end, errObj = parseOptionalClock(&legacyEndRaw, legacyPointer)
if errObj != nil {
return nil, nil, errObj
}
}
return start, end, nil
}
func parseBaseDefaultShiftRange(
startRaw *string,
endRaw *string,
legacyRaw *string,
startTimeType string,
endTimeType string,
requireFixedTimes bool,
startPointer string,
endPointer string,
legacyPointer string,
) (*time.Time, *time.Time, *jsonapi.ErrorObject) {
start, end, errObj := parseBaseShiftRange(startRaw, endRaw, legacyRaw, startPointer, endPointer, legacyPointer)
if errObj != nil {
return nil, nil, errObj
}
startTimeType = normalizeBaseShiftTimeTypeOrFixed(startTimeType)
endTimeType = normalizeBaseShiftTimeTypeOrFixed(endTimeType)
if requireFixedTimes && startTimeType == base.ShiftTimeTypeFixed && start == nil {
return nil, nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "default_shift_start is required when default_start_time_type is FIXED",
Source: &jsonapi.ErrorSource{Pointer: startPointer},
}
}
if requireFixedTimes && endTimeType == base.ShiftTimeTypeFixed && end == nil {
return nil, nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "default_shift_end is required when default_end_time_type is FIXED",
Source: &jsonapi.ErrorSource{Pointer: endPointer},
}
}
if start != nil && end != nil && start.After(*end) {
return nil, nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "default_shift_start must be earlier than default_shift_end",
Source: &jsonapi.ErrorSource{Pointer: legacyPointer},
}
}
if startTimeType != base.ShiftTimeTypeFixed {
start = nil
}
if endTimeType != base.ShiftTimeTypeFixed {
end = nil
}
return start, end, nil
}
func parseBaseOperationalShiftTimes(items []dto.BaseOperationalShiftTime, basePointer string) ([]appservice.BaseOperationalShiftTimeInput, *jsonapi.ErrorObject) {
if len(items) == 0 {
return nil, nil
}
out := make([]appservice.BaseOperationalShiftTimeInput, 0, len(items))
for i := range items {
idxPointer := fmt.Sprintf("%s/%d", basePointer, i)
startRaw := strings.TrimSpace(items[i].DateStart)
endRaw := strings.TrimSpace(items[i].DateEnd)
if startRaw == "" || endRaw == "" {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "date_start and date_end are required",
Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/date_start"},
}
}
dateStart, err := time.Parse("2006-01-02", startRaw)
if err != nil {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "date_start must be YYYY-MM-DD",
Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/date_start"},
}
}
dateEnd, err := time.Parse("2006-01-02", endRaw)
if err != nil {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "date_end must be YYYY-MM-DD",
Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/date_end"},
}
}
if dateEnd.Before(dateStart) {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "date_end must be >= date_start",
Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/date_end"},
}
}
shiftStartRaw := strings.TrimSpace(items[i].ShiftTime.Start)
shiftEndRaw := strings.TrimSpace(items[i].ShiftTime.End)
startTimeType, errObj := normalizeBaseShiftTimeType(items[i].StartTimeType, idxPointer+"/start_time_type")
if errObj != nil {
return nil, errObj
}
endTimeType, errObj := normalizeBaseShiftTimeType(items[i].EndTimeType, idxPointer+"/end_time_type")
if errObj != nil {
return nil, errObj
}
if startTimeType == base.ShiftTimeTypeFixed && shiftStartRaw == "" {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "shift_time.start is required when start_time_type is FIXED",
Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/shift_time/start"},
}
}
if endTimeType == base.ShiftTimeTypeFixed && shiftEndRaw == "" {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "shift_time.end is required when end_time_type is FIXED",
Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/shift_time/end"},
}
}
var start *time.Time
var end *time.Time
var clockErr *jsonapi.ErrorObject
if shiftStartRaw != "" {
start, clockErr = parseOptionalClock(&shiftStartRaw, idxPointer+"/shift_time/start")
if clockErr != nil {
return nil, clockErr
}
}
if shiftEndRaw != "" {
end, clockErr = parseOptionalClock(&shiftEndRaw, idxPointer+"/shift_time/end")
if clockErr != nil {
return nil, clockErr
}
}
if start != nil && end != nil && start.After(*end) {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "shift_time.start must be earlier than shift_time.end",
Source: &jsonapi.ErrorSource{Pointer: idxPointer + "/shift_time"},
}
}
out = append(out, appservice.BaseOperationalShiftTimeInput{
DateStart: dateStart.UTC(),
DateEnd: dateEnd.UTC(),
StartTimeType: startTimeType,
EndTimeType: endTimeType,
ShiftStart: start,
ShiftEnd: end,
})
}
return out, nil
}
func validateBaseCoordinates(lat, lon *float64, latPointer, lonPointer string) *jsonapi.ErrorObject {
if lat == nil || lon == nil {
return &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "latitude and longitude are required",
Source: &jsonapi.ErrorSource{Pointer: latPointer},
}
}
if errObj := validateCoordinateRange(*lat, -90, 90, "latitude", latPointer); errObj != nil {
return errObj
}
if errObj := validateCoordinateRange(*lon, -180, 180, "longitude", lonPointer); errObj != nil {
return errObj
}
return nil
}
func validateOptionalBaseCoordinates(lat, lon *float64, latPointer, lonPointer string) *jsonapi.ErrorObject {
if lat == nil && lon == nil {
return nil
}
if lat == nil || lon == nil {
pointer := latPointer
if lat == nil {
pointer = latPointer
} else {
pointer = lonPointer
}
return &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "latitude and longitude must be provided together",
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
if errObj := validateCoordinateRange(*lat, -90, 90, "latitude", latPointer); errObj != nil {
return errObj
}
if errObj := validateCoordinateRange(*lon, -180, 180, "longitude", lonPointer); errObj != nil {
return errObj
}
return nil
}
func validateCoordinateRange(value, min, max float64, label, pointer string) *jsonapi.ErrorObject {
if math.IsNaN(value) || value < min || value > max {
return &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: fmt.Sprintf("%s must be between %.0f and %.0f", label, min, max),
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
return nil
}
func extractBaseShiftTimeFromUpdateBody(body []byte) *dto.BaseShiftTime {
var payload struct {
Data struct {
Attributes struct {
ShiftTime *dto.BaseShiftTime `json:"shift_time"`
} `json:"attributes"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil
}
return payload.Data.Attributes.ShiftTime
}
func parseOptionalClock(v *string, pointer string) (*time.Time, *jsonapi.ErrorObject) {
if v == nil {
return nil, nil
}
s := strings.TrimSpace(*v)
if s == "" {
return nil, nil
}
if len(s) == 5 {
s += ":00"
}
parsed, err := time.Parse("15:04:05", s)
if err != nil {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "time must be HH:MM or HH:MM:SS",
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
val := time.Date(2000, 1, 1, parsed.Hour(), parsed.Minute(), parsed.Second(), 0, time.UTC)
return &val, nil
}
func normalizeBaseShiftTimeType(raw string, pointer string) (string, *jsonapi.ErrorObject) {
switch strings.ToUpper(strings.TrimSpace(raw)) {
case "", base.ShiftTimeTypeFixed:
return base.ShiftTimeTypeFixed, nil
case base.ShiftTimeTypeBMCT:
return base.ShiftTimeTypeBMCT, nil
case base.ShiftTimeTypeECET:
return base.ShiftTimeTypeECET, nil
default:
return "", &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "start_time_type and end_time_type must be FIXED, BMCT, or ECET",
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
}
func normalizeBaseShiftTimeTypeOrFixed(raw string) string {
value, errObj := normalizeBaseShiftTimeType(raw, "")
if errObj != nil {
return strings.ToUpper(strings.TrimSpace(raw))
}
return value
}
func float64OrDefault(v *float64, d float64) float64 {
if v == nil {
return d
}
return *v
}
func extractBaseShiftRangeTokens(raw string) (string, string) {
tokens := extractBaseShiftClockTokens(raw)
if len(tokens) == 0 {
return "", ""
}
if len(tokens) == 1 {
return tokens[0], tokens[0]
}
return tokens[0], tokens[1]
}
func extractBaseShiftClockTokens(raw string) []string {
s := strings.TrimSpace(raw)
if s == "" {
return nil
}
out := make([]string, 0, 2)
for i := 0; i < len(s) && len(out) < 2; i++ {
if i+5 > len(s) || !isDigitBaseClock(s[i]) || !isDigitBaseClock(s[i+1]) || s[i+2] != ':' || !isDigitBaseClock(s[i+3]) || !isDigitBaseClock(s[i+4]) {
continue
}
token := s[i : i+5]
if i+8 <= len(s) && s[i+5] == ':' && isDigitBaseClock(s[i+6]) && isDigitBaseClock(s[i+7]) {
token = s[i : i+8]
}
out = append(out, token)
}
return out
}
func isDigitBaseClock(b byte) bool {
return b >= '0' && b <= '9'
}
func clockStringOrEmpty(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return normalizeBaseShiftDisplay(t)
case *time.Time:
if t == nil {
return ""
}
return normalizeBaseShiftDisplay(t.Format("15:04:05"))
default:
return ""
}
}
func normalizeBaseShiftDisplay(raw string) string {
s := strings.TrimSpace(raw)
if len(s) >= 5 && isDigitBaseClock(s[0]) && isDigitBaseClock(s[1]) && s[2] == ':' && isDigitBaseClock(s[3]) && isDigitBaseClock(s[4]) {
return s[:5]
}
return s
}
func baseCategoryFromRowOrType(row *base.Base, categoryType string) string {
if row != nil {
key := strings.TrimSpace(row.BaseCategory.Key)
if key != "" {
if fromRelation, ok := base.NormalizeCategoryType(key); ok {
return fromRelation
}
}
}
if fromType, ok := base.NormalizeCategoryType(categoryType); ok {
return fromType
}
return base.CategoryKeyRegular
}
func parseBaseCategoryTypeQuery(c *fiber.Ctx) (string, *jsonapi.ErrorObject) {
raw := baseCategoryQueryValue(c)
categoryType, ok := base.NormalizeCategoryType(raw)
if !ok {
return "", &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "category_type/base_category must be one of: regular, hems",
Source: &jsonapi.ErrorSource{Pointer: "/query/category_type"},
}
}
return categoryType, nil
}
func parseOptionalBaseCategoryTypeQuery(c *fiber.Ctx) (string, *jsonapi.ErrorObject) {
raw := baseCategoryQueryValue(c)
if raw == "" {
return "", nil
}
categoryType, ok := base.NormalizeCategoryType(raw)
if !ok {
return "", &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "category_type/base_category must be one of: regular, hems",
Source: &jsonapi.ErrorSource{Pointer: "/query/category_type"},
}
}
return categoryType, nil
}
func parseOptionalBoolQueryParam(c *fiber.Ctx, queryKey string) (*bool, *jsonapi.ErrorObject) {
raw := strings.TrimSpace(c.Query(queryKey))
if raw == "" {
return nil, nil
}
switch strings.ToLower(raw) {
case "true", "1":
v := true
return &v, nil
case "false", "0":
v := false
return &v, nil
default:
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: queryKey + " must be true or false",
Source: &jsonapi.ErrorSource{Pointer: "/query/" + queryKey},
}
}
}
func baseCategoryQueryValue(c *fiber.Ctx) string {
raw := strings.TrimSpace(c.Query("category_type"))
if raw == "" {
raw = strings.TrimSpace(c.Query("base_category"))
}
if raw == "" {
raw = strings.TrimSpace(c.Query("category"))
}
return raw
}
func resolveBaseCategoryType(c *fiber.Ctx, bodyCategory *string, pointer string) (string, *jsonapi.ErrorObject) {
if bodyCategory != nil {
categoryType, ok := base.NormalizeCategoryType(*bodyCategory)
if !ok {
return "", &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "base_category must be one of: regular, hems",
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
return categoryType, nil
}
return parseBaseCategoryTypeQuery(c)
}
func categoryNotFoundDetail(categoryType string) string {
if categoryType == base.CategoryKeyHEMS {
return "hems base not found"
}
return "base not found"
}
func parseOptionalUUID(v *string, pointer string) ([]byte, *jsonapi.ErrorObject) {
if v == nil {
return nil, nil
}
s := strings.TrimSpace(*v)
if s == "" {
return nil, nil
}
parsed, err := uuidv7.ParseString(s)
if err != nil {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
return parsed, nil
}
func parseUUIDOrClear(v string, pointer string) ([]byte, *jsonapi.ErrorObject) {
s := strings.TrimSpace(v)
if s == "" {
return nil, nil
}
parsed, err := uuidv7.ParseString(s)
if err != nil {
return nil, &jsonapi.ErrorObject{
Status: "422",
Title: "Validation error",
Detail: "invalid UUID",
Source: &jsonapi.ErrorSource{Pointer: pointer},
}
}
return parsed, nil
}
func bytesUUIDToString(raw []byte) string {
if len(raw) == 0 {
return ""
}
if name := userctx.GetDisplayName(context.TODO(), raw); name != "" {
return name
}
s, err := uuidv7.BytesToString(raw)
if err != nil {
return ""
}
return s
}
func writeBaseErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error {
statusStr := strconv.Itoa(status)
enriched := make([]jsonapi.ErrorObject, 0, len(errs))
for i := range errs {
e := errs[i]
e.Status = statusStr
def := inferBaseDef(status, e.Title, e.Detail)
if strings.TrimSpace(e.Code) == "" {
e.Code = def.Code
}
if strings.TrimSpace(e.ErrorCode) == "" {
e.ErrorCode = def.ErrorCode
}
if strings.TrimSpace(e.Title) == "" {
e.Title = def.Title
}
if e.Source != nil && shouldPreserveBaseDetail(e.Source.Pointer) {
if strings.TrimSpace(e.Detail) == "" {
e.Detail = def.Message
}
} else {
e.Detail = def.Message
}
enriched = append(enriched, e)
}
return response.WriteErrors(c, status, enriched)
}
func shouldPreserveBaseDetail(pointer string) bool {
lowerPointer := strings.ToLower(strings.TrimSpace(pointer))
return strings.Contains(lowerPointer, "operational_shift_times") ||
strings.Contains(lowerPointer, "latitude") ||
strings.Contains(lowerPointer, "longitude") ||
strings.Contains(lowerPointer, "twilight") ||
strings.Contains(lowerPointer, "shift_time")
}
func inferBaseDef(status int, title, detail string) apperrorsx.Def {
lowerTitle := strings.ToLower(strings.TrimSpace(title))
lowerDetail := strings.ToLower(strings.TrimSpace(detail))
switch status {
case fiber.StatusNotFound:
return apperrorsx.ErrBaseNotFound
case fiber.StatusConflict:
return apperrorsx.ErrBaseRelationDelete
case fiber.StatusUnprocessableEntity:
switch {
case strings.Contains(lowerDetail, "uuid is invalid uuid"), strings.Contains(lowerDetail, "invalid uuid"):
return apperrorsx.ErrBaseInvalidUUID
case strings.Contains(lowerDetail, "base_category must be one of"),
strings.Contains(lowerDetail, "category_type/base_category must be one of"):
return apperrorsx.ErrBaseInvalidCategory
case strings.Contains(lowerDetail, "at least one attribute must be provided"):
return apperrorsx.ErrBaseAttributesRequired
case strings.Contains(lowerDetail, "base is required"), strings.Contains(lowerDetail, "base cannot be empty"):
return apperrorsx.ErrBaseNameRequired
case strings.Contains(lowerDetail, "default_shift_start is required"):
return apperrorsx.ErrBaseDefaultShiftStartRequired
case strings.Contains(lowerDetail, "default_shift_end is required"):
return apperrorsx.ErrBaseDefaultShiftEndRequired
case strings.Contains(lowerDetail, "time must be"), strings.Contains(lowerDetail, "default_shift_time must contain"):
return apperrorsx.ErrBaseInvalidTime
case strings.Contains(lowerDetail, "contact id"), strings.Contains(lowerDetail, "value is invalid uuid"):
return apperrorsx.ErrBaseInvalidContactIDs
case strings.Contains(lowerDetail, "shift_time.start must be earlier than shift_time.end"),
strings.Contains(lowerDetail, "shift_time.start is required when start_time_type is fixed"),
strings.Contains(lowerDetail, "shift_time.end is required when end_time_type is fixed"),
strings.Contains(lowerDetail, "latitude"),
strings.Contains(lowerDetail, "longitude"),
strings.Contains(lowerDetail, "coordinates"),
strings.Contains(lowerDetail, "twilight"):
def := apperrorsx.ErrBaseInvalidPayload
def.Message = detail
return def
default:
return apperrorsx.ErrBaseInvalidPayload
}
case fiber.StatusBadRequest:
switch {
case strings.Contains(lowerTitle, "invalid json"):
return apperrorsx.ErrBaseInvalidJSON
case strings.Contains(lowerTitle, "create failed"):
return apperrorsx.ErrBaseCreateFailed
case strings.Contains(lowerTitle, "update failed"):
return apperrorsx.ErrBaseUpdateFailed
case strings.Contains(lowerTitle, "delete failed"):
return apperrorsx.ErrBaseDeleteFailed
case strings.Contains(lowerTitle, "list failed"):
return apperrorsx.ErrBaseListFailed
default:
return apperrorsx.ErrBaseInvalidPayload
}
default:
return apperrorsx.ErrInternal
}
}
func inferBaseErrorCode(status int, title, detail string) (string, string) {
def := inferBaseDef(status, title, detail)
return def.Code, def.ErrorCode
}