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":"","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 }