package handlers import ( "context" "encoding/hex" "errors" "fmt" "strings" "time" "github.com/gofiber/fiber/v2" "gorm.io/gorm" "wucher/internal/domain/auth" "wucher/internal/domain/contact" filemanager "wucher/internal/domain/file_manager" "wucher/internal/shared/pkg/apperrorsx" "wucher/internal/shared/pkg/attachmentresolver" "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 ( contactProfileAttachmentRefType = "user_profile" contactProfileAttachmentCategory = "profile_picture" contactListMaxLimit = 1000 ) type ContactHandler struct { svc contact.Service fileManager filemanager.Service storage fileManagerDownloadURLStorage inviter contactInviter validate *validators.Validator } type contactInviter interface { SendInviteSetPasswordForUser(ctx context.Context, userID []byte) error } func NewContactHandler(svc contact.Service, inviter contactInviter) *ContactHandler { return &ContactHandler{svc: svc, inviter: inviter, validate: validators.New()} } func (h *ContactHandler) WithFileManagerService(fileManager filemanager.Service) *ContactHandler { h.fileManager = fileManager return h } func (h *ContactHandler) WithFileStorage(storage fileManagerDownloadURLStorage) *ContactHandler { h.storage = storage return h } type contactProfilePayload struct { roleCode string field string present bool } // CreateContact godoc // @Summary Create contact // @Description Create new contact user and role-specific profile. // @Description `data.type` must be `contact_create`. // @Description `role_ids` determines roles (use `role_id` only for backward compatibility). Every selected role must include its matching role-specific profile object in `attributes`. // @Tags Contacts // @Accept json // @Produce json // @Param request body dto.ContactCreateRequest true "JSON:API Contact create request" // @Success 201 {object} dto.ContactListResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/contacts/create [post] func (h *ContactHandler) Create(c *fiber.Ctx) error { var req dto.ContactCreateRequest if err := c.BodyParser(&req); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred", }}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } if errs := validatePilotDULBaseCreateInput(req.Data.Attributes); len(errs) > 0 { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } primaryRoleID, roleIDs, errObjs := parseContactCreateRoleIDs(req.Data.Attributes) if len(errObjs) > 0 { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errObjs) } roleCodes := make([]string, 0, len(roleIDs)) for i := range roleIDs { roleCode, err := h.svc.ResolveRoleCodeByID(c.UserContext(), roleIDs[i]) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{ contactValidationError("/data/attributes/role_ids", "one or more role_ids do not exist"), }) } return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Create failed", Detail: "an internal error occurred"}}) } roleCodes = append(roleCodes, roleCode) } if len(roleCodes) == 0 { roleCodes = inferContactCreateRoleCodes(req.Data.Attributes) } if errs := validateContactCreatePayload(req.Data.Type, roleCodes, req.Data.Attributes); len(errs) > 0 { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } in := contact.CreateInput{ RoleID: primaryRoleID, RoleIDs: roleIDs, User: contact.UserPatch{ Email: strPtr(strings.TrimSpace(req.Data.Attributes.Email)), SSOEmail: emptyToNil(strings.TrimSpace(req.Data.Attributes.SSOEmail)), IsAdmin: req.Data.Attributes.IsAdmin, Username: strPtr(strings.TrimSpace(req.Data.Attributes.Username)), FirstName: strPtr(strings.TrimSpace(req.Data.Attributes.FirstName)), LastName: strPtr(strings.TrimSpace(req.Data.Attributes.LastName)), MobilePhone: strPtr(strings.TrimSpace(req.Data.Attributes.MobilePhone)), Timezone: strPtr(strings.TrimSpace(req.Data.Attributes.Timezone)), SortKey: cloneIntPointer(req.Data.Attributes.SortKey), IsActive: req.Data.Attributes.IsActive, }, } for i := range roleCodes { in.Profiles = append(in.Profiles, contact.CreateRoleProfile{ RoleCode: roleCodes[i], Patch: profilePatchFromCreate(roleCodes[i], req.Data.Attributes), }) } if len(in.Profiles) > 0 { in.Profile = in.Profiles[0].Patch } userID, err := h.svc.CreateContact(c.UserContext(), in) if err != nil { if errors.Is(err, contact.ErrUsernameAlreadyExists) { return writeContactErrors(c, fiber.StatusConflict, []jsonapi.ErrorObject{{ Status: "409", Title: "Conflict", Detail: "username already exists", }}) } return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Create failed", Detail: "an internal error occurred", }}) } if rawFileUUID := strings.TrimSpace(req.Data.Attributes.FileUUID); rawFileUUID != "" { attachmentID, handled := h.resolveContactProfileAttachmentID(c, userID, &rawFileUUID) if handled { return nil } if err := h.svc.UpdateContact(c.UserContext(), contact.UpdateInput{ UserID: userID, User: contact.UserPatch{ ProfileAttachmentID: &attachmentID, }, }); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Create failed", Detail: "an internal error occurred", }}) } } inviteSent := false inviteError := "" if h.inviter != nil { if err := h.inviter.SendInviteSetPasswordForUser(c.UserContext(), userID); err != nil { inviteError = "an internal error occurred" } else { inviteSent = true } } item, err := h.svc.GetContactByID(c.UserContext(), userID) if err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "Get failed", Detail: "an internal error occurred", }}) } if item == nil { return response.Write(c, fiber.StatusCreated, jsonapi.Resource{ Type: "contact", ID: contactUUIDString(userID), Attributes: map[string]any{ "created": true, "invite_sent": inviteSent, "invite_error": inviteError, }, }) } isAdmin, err := h.svc.IsActorAdmin(c.UserContext()) if err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Create failed", Detail: "an internal error occurred"}}) } attrs := contactAttributes(item, isAdmin) h.applyContactFoto(c.UserContext(), item, attrs) attrs["invite_sent"] = inviteSent attrs["invite_error"] = inviteError return response.Write(c, fiber.StatusCreated, jsonapi.Resource{ Type: "contact", ID: contactUUIDString(item.UserID), Attributes: attrs, }) } // ListContacts godoc // @Summary List contacts // @Description Filter contacts by role and pilot_category. Sort values: name, -name, first_name, -first_name, last_name, -last_name, email, -email, sortkey, -sortkey, note, -note, is_active, -is_active, created_at, -created_at, updated_at, -updated_at. // @Tags Contacts // @Produce json // @Param role query string false "Role: pilot|doctor|air_rescuer|technician|flight_assistant|staff" // @Param pilot_category query string false "Pilot category: regular|freelance|dry_lease" // @Param chief_doctor query bool false "Filter doctor chief flag" // @Param chief_technician query bool false "Filter technician chief flag" // @Param responsible_complaint query bool false "Filter responsible complaint flag" // @Param chief_pilot query bool false "Filter pilot chief flag" // @Param ifr_qualified query bool false "Filter IFR qualified flag" // @Param search query string false "Search by username/email/name" // @Param sort query string false "Sort order" // @Param limit query int false "Limit (default 20)" // @Param offset query int false "Offset (default 0)" // @Success 200 {object} dto.ContactListResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/contacts/get-all [get] func (h *ContactHandler) List(c *fiber.Ctx) error { isAdmin, err := h.svc.IsActorAdmin(c.UserContext()) if err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: "an internal error occurred", }}) } limit := 0 offset := 0 filter := contact.ListFilter{ Role: normalizeContactRoleFilter(c.Query("role")), PilotCategory: strings.TrimSpace(c.Query("pilot_category")), ChiefDoctor: parseOptionalBoolQuery(c.Query("chief_doctor")), ChiefTechnician: parseOptionalBoolQuery(c.Query("chief_technician")), ResponsibleComplaint: parseOptionalBoolQuery(c.Query("responsible_complaint")), ChiefPilot: parseOptionalBoolQuery(c.Query("chief_pilot")), IFRQualified: parseOptionalBoolQuery(c.Query("ifr_qualified")), Search: strings.TrimSpace(c.Query("search")), Sort: strings.TrimSpace(c.Query("sort")), Limit: limit, Offset: offset, } items, total, err := h.svc.ListContacts(c.UserContext(), filter) if err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: "an internal error occurred", }}) } out := make([]jsonapi.Resource, 0, len(items)) for i := range items { attrs := contactAttributes(&items[i], isAdmin) h.applyContactFoto(c.UserContext(), &items[i], attrs) out = append(out, jsonapi.Resource{ Type: "contact", ID: contactUUIDString(items[i].UserID), Attributes: attrs, }) } meta := map[string]any{"total": total, "limit": limit, "offset": offset} return response.WriteWithMeta(c, fiber.StatusOK, out, meta) } // ListContactsDatatable godoc // @Summary List contacts (datatable) // @Description Datatable response with pagination params. // @Tags Contacts // @Produce json // @Param page query int false "Page number (default 1)" // @Param size query int false "Page size (default 20, max 1000)" // @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 role query string false "Role filter" // @Param pilot_category query string false "Pilot category filter" // @Param chief_doctor query bool false "Filter doctor chief flag" // @Param chief_technician query bool false "Filter technician chief flag" // @Param responsible_complaint query bool false "Filter responsible complaint flag" // @Param chief_pilot query bool false "Filter pilot chief flag" // @Param ifr_qualified query bool false "Filter IFR qualified flag" // @Success 200 {object} dto.ContactDataTableResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/contacts/get-all/dt [get] func (h *ContactHandler) ListDatatable(c *fiber.Ctx) error { isAdmin, err := h.svc.IsActorAdmin(c.UserContext()) if err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: "an internal error occurred", }}) } 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 > contactListMaxLimit { length = contactListMaxLimit } if length < 1 { length = 20 } start := (pageNumber - 1) * length draw := parseIntDefault(c.Query("draw"), pageNumber) filter := contact.ListFilter{ Role: normalizeContactRoleFilter(c.Query("role")), PilotCategory: strings.TrimSpace(c.Query("pilot_category")), ChiefDoctor: parseOptionalBoolQuery(c.Query("chief_doctor")), ChiefTechnician: parseOptionalBoolQuery(c.Query("chief_technician")), ResponsibleComplaint: parseOptionalBoolQuery(c.Query("responsible_complaint")), ChiefPilot: parseOptionalBoolQuery(c.Query("chief_pilot")), IFRQualified: parseOptionalBoolQuery(c.Query("ifr_qualified")), Search: strings.TrimSpace(c.Query("search")), Sort: strings.TrimSpace(c.Query("sort")), Limit: length, Offset: start, } items, total, err := h.svc.ListContacts(c.UserContext(), filter) if err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{ Status: "400", Title: "List failed", Detail: "an internal error occurred", }}) } out := make([]jsonapi.Resource, 0, len(items)) for i := range items { attrs := contactAttributes(&items[i], isAdmin) h.applyContactFoto(c.UserContext(), &items[i], attrs) out = append(out, jsonapi.Resource{ Type: "contact", ID: contactUUIDString(items[i].UserID), Attributes: attrs, }) } meta := map[string]any{ "draw": draw, "records_total": total, "records_filtered": total, } return response.WriteWithMeta(c, fiber.StatusOK, out, meta) } // GetContact godoc // @Summary Get contact // @Tags Contacts // @Produce json // @Param user_id path string true "User UUID" // @Success 200 {object} dto.ContactListResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/contacts/{user_id} [get] func (h *ContactHandler) Get(c *fiber.Ctx) error { userID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("user_id"))) if err != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/user_id"}, }}) } return h.writeContactByID(c, userID) } func (h *ContactHandler) writeContactByID(c *fiber.Ctx, userID []byte) error { item, err := h.svc.GetContactByID(c.UserContext(), userID) if err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Get failed", Detail: "an internal error occurred"}}) } if item == nil { return writeContactErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{Status: "404", Title: "Not found", Detail: "contact not found"}}) } isAdmin, err := h.svc.IsActorAdmin(c.UserContext()) if err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Get failed", Detail: "an internal error occurred"}}) } attrs := contactAttributes(item, isAdmin) h.applyContactFoto(c.UserContext(), item, attrs) return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "contact", ID: contactUUIDString(item.UserID), Attributes: attrs}) } // UpdateContact godoc // @Summary Update contact // @Description `data.type` must be `contact_update`. // @Description Supports updating `role_id` and `role_ids` (multiple roles) in the same request. Sending `role_ids: []` clears all assigned roles. // @Description Only one role-specific profile object is allowed in `attributes`. If provided, it must match one of the contact's assigned roles. // @Tags Contacts // @Accept json // @Produce json // @Param user_id path string true "User UUID" // @Param request body dto.ContactUpdateRequest true "JSON:API Contact update request" // @Success 200 {object} dto.ContactListResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/contacts/{user_id} [patch] func (h *ContactHandler) Update(c *fiber.Ctx) error { userID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("user_id"))) if err != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/path/user_id"}, }}) } var req dto.ContactUpdateRequest if err := c.BodyParser(&req); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}}) } ssoEmailCleared := normalizeContactUpdateSSOEmailForValidation(&req) if errs := h.validate.ValidateStruct(req); errs != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } if errs := validatePilotDULBaseUpdateInput(req.Data.Attributes); len(errs) > 0 { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } if ssoEmailCleared { empty := "" req.Data.Attributes.SSOEmail = &empty } if strings.TrimSpace(req.Data.Type) != contactUpdateType() { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{ contactValidationError("/data/type", fmt.Sprintf("type must be %s", contactUpdateType())), }) } current, err := h.svc.GetContactByID(c.UserContext(), userID) if err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Get failed", Detail: "an internal error occurred"}}) } if current == nil { return writeContactErrors(c, fiber.StatusNotFound, []jsonapi.ErrorObject{{Status: "404", Title: "Not found", Detail: "contact not found"}}) } currentRoleCode := normalizeContactRoleCode(current.RoleCode) nextPrimaryRoleID, nextRoleIDs, nextRoleCodes, updateRoles, roleErrs := h.parseContactUpdateRoleIDs(c, current, req.Data.Attributes) if len(roleErrs) > 0 { return writeContactErrors(c, fiber.StatusUnprocessableEntity, roleErrs) } allowedRoleCodes := assignedContactRoleCodes(current) if updateRoles { allowedRoleCodes = nextRoleCodes if errs := validateRoleSpecificCreatePayload(nextRoleCodes, req.Data.Type, profilePayloadsFromUpdate(req.Data.Attributes)); len(errs) > 0 { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } } requestedProfileRoleCodes, errs := validateRoleSpecificPayload(allowedRoleCodes, req.Data.Type, profilePayloadsFromUpdate(req.Data.Attributes)) if len(errs) > 0 { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } roleCodeForAdminValidation := currentRoleCode if len(requestedProfileRoleCodes) > 0 { roleCodeForAdminValidation = requestedProfileRoleCodes[0] } _ = updateRoles profiles := make([]contact.CreateRoleProfile, 0, len(requestedProfileRoleCodes)) for i := range requestedProfileRoleCodes { profiles = append(profiles, contact.CreateRoleProfile{ RoleCode: requestedProfileRoleCodes[i], Patch: profilePatchFromUpdate(requestedProfileRoleCodes[i], req.Data.Attributes), }) } in := contact.UpdateInput{ UserID: userID, User: contact.UserPatch{ Email: req.Data.Attributes.Email, SSOEmail: req.Data.Attributes.SSOEmail, IsAdmin: req.Data.Attributes.IsAdmin, Username: req.Data.Attributes.Username, FirstName: req.Data.Attributes.FirstName, LastName: req.Data.Attributes.LastName, MobilePhone: req.Data.Attributes.MobilePhone, Timezone: req.Data.Attributes.Timezone, SortKeySet: req.Data.Attributes.SortKey.Set, IsActive: req.Data.Attributes.IsActive, }, RoleID: nextPrimaryRoleID, RoleIDs: nextRoleIDs, UpdateRoles: updateRoles, Profile: profilePatchFromUpdate(roleCodeForAdminValidation, req.Data.Attributes), Profiles: profiles, TargetProfileRole: roleCodeForAdminValidation, } if req.Data.Attributes.SortKey.Set && req.Data.Attributes.SortKey.Valid { sortKey := req.Data.Attributes.SortKey.Value in.User.SortKey = &sortKey } if req.Data.Attributes.FileUUID != nil { rawFileUUID := strings.TrimSpace(*req.Data.Attributes.FileUUID) if rawFileUUID == "" { empty := []byte(nil) in.User.ProfileAttachmentID = &empty } else { attachmentID, handled := h.resolveContactProfileAttachmentID(c, userID, &rawFileUUID) if handled { return nil } in.User.ProfileAttachmentID = &attachmentID } } if err := h.svc.UpdateContact(c.UserContext(), in); err != nil { status := fiber.StatusBadRequest if errors.Is(err, gorm.ErrRecordNotFound) { status = fiber.StatusNotFound } else if errors.Is(err, contact.ErrUsernameAlreadyExists) { status = fiber.StatusConflict } title := "Update failed" if status == fiber.StatusConflict { title = "Conflict" } return writeContactErrors(c, status, []jsonapi.ErrorObject{{Status: intStatus(status), Title: title, Detail: "an internal error occurred"}}) } return h.writeContactByID(c, userID) } // DeleteContact godoc // @Summary Delete contact // @Tags Contacts // @Produce json // @Param user_id path string true "User UUID" // @Success 200 {object} dto.ContactListResponse // @Failure 404 {object} dto.ErrorResponse // @Router /api/v1/contacts/{user_id} [delete] func (h *ContactHandler) Delete(c *fiber.Ctx) error { userID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("user_id"))) if err != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID"}}) } if err := h.svc.DeleteContact(c.UserContext(), userID); err != nil { status := fiber.StatusBadRequest if errors.Is(err, gorm.ErrRecordNotFound) { status = fiber.StatusNotFound } return writeContactErrors(c, status, []jsonapi.ErrorObject{{Status: intStatus(status), Title: "Delete failed", Detail: "an internal error occurred"}}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "contact_delete", Attributes: map[string]any{"deleted": true}}) } // UpdateContactStatus godoc // @Summary Update contact active status // @Tags Contacts // @Accept json // @Produce json // @Param user_id path string true "User UUID" // @Param request body dto.ContactStatusUpdateRequest true "JSON:API status update request" // @Success 200 {object} dto.ContactSimpleSuccessResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/contacts/{user_id}/status [patch] func (h *ContactHandler) UpdateContactStatus(c *fiber.Ctx) error { userID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("user_id"))) if err != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID"}}) } var req dto.ContactStatusUpdateRequest if err := c.BodyParser(&req); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } if err := h.svc.UpdateContactStatus(c.UserContext(), userID, req.Data.Attributes.IsActive); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Update status failed", Detail: "an internal error occurred"}}) } return h.writeContactByID(c, userID) } // UpdateContactRole godoc // @Summary Update contact role // @Tags Contacts // @Accept json // @Produce json // @Param user_id path string true "User UUID" // @Param request body dto.ContactRoleUpdateRequest true "JSON:API role update request" // @Success 200 {object} dto.ContactListResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/contacts/{user_id}/role [patch] func (h *ContactHandler) UpdateContactRole(c *fiber.Ctx) error { userID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("user_id"))) if err != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID"}}) } var req dto.ContactRoleUpdateRequest if err := c.BodyParser(&req); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } if err := h.svc.UpdateContactRole(c.UserContext(), userID, req.Data.Attributes.RoleCode); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Update role failed", Detail: "an internal error occurred"}}) } return h.writeContactByID(c, userID) } // UpdatePilotCategory godoc // @Summary Update pilot category // @Tags Contacts // @Accept json // @Produce json // @Param user_id path string true "User UUID" // @Param request body dto.ContactPilotCategoryUpdateRequest true "JSON:API pilot category request" // @Success 200 {object} dto.ContactListResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/contacts/{user_id}/pilot-category [patch] func (h *ContactHandler) UpdatePilotCategory(c *fiber.Ctx) error { userID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("user_id"))) if err != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID"}}) } var req dto.ContactPilotCategoryUpdateRequest if err := c.BodyParser(&req); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } if err := h.svc.UpdatePilotCategory(c.UserContext(), userID, req.Data.Attributes.PilotCategory); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Update pilot category failed", Detail: "an internal error occurred"}}) } return h.writeContactByID(c, userID) } // UpdateLicense godoc // @Summary Update contact license fields // @Tags Contacts // @Accept json // @Produce json // @Param user_id path string true "User UUID" // @Param request body dto.ContactLicenseUpdateRequest true "JSON:API license update request" // @Success 200 {object} dto.ContactListResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/contacts/{user_id}/license [patch] func (h *ContactHandler) UpdateLicense(c *fiber.Ctx) error { userID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("user_id"))) if err != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID"}}) } var req dto.ContactLicenseUpdateRequest if err := c.BodyParser(&req); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } if err := h.svc.UpdateLicense(c.UserContext(), userID, req.Data.Attributes.LicenseNo); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Update license failed", Detail: "an internal error occurred"}}) } return h.writeContactByID(c, userID) } // UpdateChiefFlags godoc // @Summary Update chief flags // @Tags Contacts // @Accept json // @Produce json // @Param user_id path string true "User UUID" // @Param request body dto.ContactChiefFlagsUpdateRequest true "JSON:API chief flags request" // @Success 200 {object} dto.ContactListResponse // @Failure 400 {object} dto.ErrorResponse // @Router /api/v1/contacts/{user_id}/chief-flags [patch] func (h *ContactHandler) UpdateChiefFlags(c *fiber.Ctx) error { userID, err := uuidv7.ParseString(strings.TrimSpace(c.Params("user_id"))) if err != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{Status: "422", Title: "Validation error", Detail: "user_id is invalid UUID"}}) } var req dto.ContactChiefFlagsUpdateRequest if err := c.BodyParser(&req); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } patch := contact.ProfilePatch{ IsChiefPilot: req.Data.Attributes.IsChiefPilot, IsChiefPhysician: req.Data.Attributes.IsChiefPhysician, IsChiefTechnician: req.Data.Attributes.IsChiefTechnician, } if err := h.svc.UpdateChiefFlags(c.UserContext(), userID, patch); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Update chief flags failed", Detail: "an internal error occurred"}}) } return h.writeContactByID(c, userID) } // BulkUpdateContacts godoc // @Summary Bulk update contacts status // @Tags Contacts // @Accept json // @Produce json // @Param request body dto.ContactBulkUpdateRequest true "JSON:API bulk update request" // @Success 200 {object} dto.ContactSimpleSuccessResponse // @Failure 400 {object} dto.ErrorResponse // @Failure 422 {object} dto.ErrorResponse // @Router /api/v1/contacts/bulk-update [post] func (h *ContactHandler) BulkUpdate(c *fiber.Ctx) error { var req dto.ContactBulkUpdateRequest if err := c.BodyParser(&req); err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Invalid JSON", Detail: "an internal error occurred"}}) } if errs := h.validate.ValidateStruct(req); errs != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, errs) } ids := make([][]byte, 0, len(req.Data.Attributes.UserIDs)) for i := range req.Data.Attributes.UserIDs { id, err := uuidv7.ParseString(strings.TrimSpace(req.Data.Attributes.UserIDs[i])) if err != nil { return writeContactErrors(c, fiber.StatusUnprocessableEntity, []jsonapi.ErrorObject{{ Status: "422", Title: "Validation error", Detail: "user_ids contains invalid UUID", Source: &jsonapi.ErrorSource{Pointer: "/data/attributes/user_ids"}, }}) } ids = append(ids, id) } count, err := h.svc.BulkUpdateStatus(c.UserContext(), ids, req.Data.Attributes.IsActive) if err != nil { return writeContactErrors(c, fiber.StatusBadRequest, []jsonapi.ErrorObject{{Status: "400", Title: "Bulk update failed", Detail: "an internal error occurred"}}) } return response.Write(c, fiber.StatusOK, jsonapi.Resource{Type: "contact_bulk_update", Attributes: map[string]any{"updated": true, "count": count}}) } func profilePatchFromCreate(roleCode string, attrs dto.ContactCreateAttributes) contact.ProfilePatch { p := contact.ProfilePatch{} switch normalizeContactRoleCode(roleCode) { case auth.RoleCodePilot: fillPatchFromPilot(&p, attrs.Pilot) case auth.RoleCodeDoctor: fillPatchFromDoctor(&p, attrs.Doctor) case auth.RoleCodeAirRescuer: fillPatchFromAirRescuer(&p, attrs.AirRescuer) case auth.RoleCodeTechnician: fillPatchFromTechnician(&p, attrs.Technician) case auth.RoleCodeFlightAssistant: fillPatchFromFlightAssistant(&p, attrs.FlightAssistant) case auth.RoleCodeStaff: fillPatchFromStaff(&p, attrs.Staff) } return p } func profilePatchFromUpdate(roleCode string, attrs dto.ContactUpdateAttributes) contact.ProfilePatch { p := contact.ProfilePatch{} switch normalizeContactRoleCode(roleCode) { case auth.RoleCodePilot: fillPatchFromPilotUpdate(&p, attrs.Pilot) case auth.RoleCodeDoctor: fillPatchFromDoctorUpdate(&p, attrs.Doctor) case auth.RoleCodeAirRescuer: fillPatchFromAirRescuerUpdate(&p, attrs.AirRescuer) case auth.RoleCodeTechnician: fillPatchFromTechnicianUpdate(&p, attrs.Technician) case auth.RoleCodeFlightAssistant: fillPatchFromFlightAssistantUpdate(&p, attrs.FlightAssistant) case auth.RoleCodeStaff: fillPatchFromStaffUpdate(&p, attrs.Staff) } return p } func writeContactErrors(c *fiber.Ctx, status int, errs []jsonapi.ErrorObject) error { return response.WriteErrors(c, status, apperrorsx.EnrichErrorObjects(status, errs)) } func inferContactErrorCode(status int, title, detail string) (string, string) { lowerTitle := strings.ToLower(strings.TrimSpace(title)) lowerDetail := strings.ToLower(strings.TrimSpace(detail)) switch status { case fiber.StatusNotFound: return apperrorsx.CodeContactNotFound, apperrorsx.ErrContactNotFound.ErrorCode case fiber.StatusUnprocessableEntity: switch { case strings.Contains(lowerDetail, "user_id is invalid uuid"): return apperrorsx.CodeContactInvalidUserID, apperrorsx.ErrContactInvalidUserID.ErrorCode default: return apperrorsx.CodeContactValidationFailed, apperrorsx.ErrContactValidationFailed.ErrorCode } case fiber.StatusBadRequest: switch { case strings.Contains(lowerTitle, "invalid json"): return apperrorsx.CodeContactInvalidJSON, apperrorsx.ErrContactInvalidJSON.ErrorCode case strings.Contains(lowerTitle, "create failed"): return apperrorsx.CodeContactCreateFailed, apperrorsx.ErrContactCreateFailed.ErrorCode case strings.Contains(lowerTitle, "get failed"): return apperrorsx.CodeContactGetFailed, apperrorsx.ErrContactGetFailed.ErrorCode case strings.Contains(lowerTitle, "list failed"): return apperrorsx.CodeContactListFailed, apperrorsx.ErrContactListFailed.ErrorCode case strings.Contains(lowerTitle, "delete failed"): return apperrorsx.CodeContactDeleteFailed, apperrorsx.ErrContactDeleteFailed.ErrorCode case strings.Contains(lowerTitle, "update status failed"): return apperrorsx.CodeContactUpdateStatusFailed, apperrorsx.ErrContactUpdateStatusFailed.ErrorCode case strings.Contains(lowerTitle, "update role failed"): return apperrorsx.CodeContactUpdateRoleFailed, apperrorsx.ErrContactUpdateRoleFailed.ErrorCode case strings.Contains(lowerTitle, "update pilot category failed"): return apperrorsx.CodeContactUpdatePilotCategoryFailed, apperrorsx.ErrContactUpdatePilotCategoryFailed.ErrorCode case strings.Contains(lowerTitle, "update license failed"): return apperrorsx.CodeContactUpdateLicenseFailed, apperrorsx.ErrContactUpdateLicenseFailed.ErrorCode case strings.Contains(lowerTitle, "update chief flags failed"): return apperrorsx.CodeContactUpdateChiefFlagsFailed, apperrorsx.ErrContactUpdateChiefFlagsFailed.ErrorCode case strings.Contains(lowerTitle, "update responsible complaints failed"): return apperrorsx.CodeContactUpdateResponsibleComplaintsFailed, apperrorsx.ErrContactUpdateResponsibleComplaintsFailed.ErrorCode case strings.Contains(lowerTitle, "bulk update failed"): return apperrorsx.CodeContactBulkUpdateFailed, apperrorsx.ErrContactBulkUpdateFailed.ErrorCode case strings.Contains(lowerTitle, "update failed"): return apperrorsx.CodeContactUpdateFailed, apperrorsx.ErrContactUpdateFailed.ErrorCode default: if strings.Contains(lowerDetail, "duplicate") || strings.Contains(lowerDetail, "already") { return apperrorsx.CodeContactInvalidPayload, apperrorsx.ErrContactInvalidPayload.ErrorCode } return apperrorsx.CodeContactInvalidPayload, apperrorsx.ErrContactInvalidPayload.ErrorCode } case fiber.StatusConflict: switch { case strings.Contains(lowerDetail, "username already exists"): return apperrorsx.CodeContactUsernameConflict, apperrorsx.ErrContactUsernameConflict.ErrorCode default: return apperrorsx.CodeContactUsernameConflict, apperrorsx.ErrContactUsernameConflict.ErrorCode } default: return apperrorsx.CodeInternalServerError, apperrorsx.ErrInternal.ErrorCode } } func validateContactCreatePayload(requestType string, resolvedRoleCodes []string, attrs dto.ContactCreateAttributes) []jsonapi.ErrorObject { if strings.TrimSpace(requestType) != contactCreateType() { return []jsonapi.ErrorObject{ contactValidationError("/data/type", fmt.Sprintf("type must be %s", contactCreateType())), } } roleCodes := make([]string, 0, len(resolvedRoleCodes)) for i := range resolvedRoleCodes { code := normalizeContactRoleCode(resolvedRoleCodes[i]) if !isSupportedContactRole(code) { return []jsonapi.ErrorObject{ contactValidationError("/data/attributes/role_ids", "one or more role_ids are not supported contact roles"), } } roleCodes = append(roleCodes, code) } errs := validateRoleSpecificCreatePayload(roleCodes, requestType, profilePayloadsFromCreate(attrs)) if len(errs) > 0 { return errs } return errs } func validateRoleSpecificCreatePayload(expectedRoleCodes []string, requestType string, payloads []contactProfilePayload) []jsonapi.ErrorObject { expected := make(map[string]struct{}, len(expectedRoleCodes)) for i := range expectedRoleCodes { expected[normalizeContactRoleCode(expectedRoleCodes[i])] = struct{}{} } errs := make([]jsonapi.ErrorObject, 0) for i := range payloads { if !payloads[i].present { continue } roleCode := normalizeContactRoleCode(payloads[i].roleCode) if _, ok := expected[roleCode]; !ok { errs = append(errs, contactValidationError("/data/attributes/"+payloads[i].field, fmt.Sprintf("%s payload is not allowed for type %s", payloads[i].field, strings.TrimSpace(requestType)))) } } for i := range payloads { if _, ok := expected[normalizeContactRoleCode(payloads[i].roleCode)]; !ok { continue } if !payloads[i].present { errs = append(errs, contactValidationError("/data/attributes/"+payloads[i].field, fmt.Sprintf("%s payload is required when role is selected", payloads[i].field))) } } return errs } func parseContactCreateRoleIDs(attrs dto.ContactCreateAttributes) ([]byte, [][]byte, []jsonapi.ErrorObject) { raw := make([]string, 0, len(attrs.RoleIDs)+1) for i := range attrs.RoleIDs { roleID := strings.TrimSpace(attrs.RoleIDs[i]) if roleID != "" { raw = append(raw, roleID) } } if strings.TrimSpace(attrs.RoleID) != "" { raw = append(raw, strings.TrimSpace(attrs.RoleID)) } if len(raw) == 0 { return nil, nil, nil } uniq := make([][]byte, 0, len(raw)) seen := make(map[string]struct{}, len(raw)) errs := make([]jsonapi.ErrorObject, 0) for i := range raw { roleID, err := uuidv7.ParseString(raw[i]) if err != nil { errs = append(errs, contactValidationError("/data/attributes/role_ids", "role_ids contains invalid UUID")) continue } key := string(roleID) if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} uniq = append(uniq, roleID) } if len(errs) > 0 { return nil, nil, errs } return uniq[0], uniq, nil } func (h *ContactHandler) parseContactUpdateRoleIDs( c *fiber.Ctx, current *contact.ContactListItem, attrs dto.ContactUpdateAttributes, ) ([]byte, [][]byte, []string, bool, []jsonapi.ErrorObject) { roleIDProvided := attrs.RoleID != nil roleIDsProvided := attrs.RoleIDs != nil roleIDTrimmed := "" roleIDExplicitClear := false if roleIDProvided { roleIDTrimmed = strings.TrimSpace(*attrs.RoleID) if roleIDTrimmed == "" || strings.EqualFold(roleIDTrimmed, "null") { roleIDExplicitClear = true roleIDProvided = false } } if !roleIDProvided && !roleIDsProvided { if roleIDExplicitClear { return nil, [][]byte{}, []string{}, true, nil } return nil, nil, nil, false, nil } if current == nil { return nil, nil, nil, false, []jsonapi.ErrorObject{ contactValidationError("/data/attributes/role_ids", "contact not found"), } } currentPrimaryRoleID, _ := roleIDBytesFromRoles(current) currentRoleIDs := roleIDBytesListFromRoles(current) primaryRoleID := currentPrimaryRoleID if roleIDProvided { parsedRoleID, err := uuidv7.ParseString(roleIDTrimmed) if err != nil { return nil, nil, nil, false, []jsonapi.ErrorObject{ contactValidationError("/data/attributes/role_id", "role_id is invalid UUID"), } } primaryRoleID = parsedRoleID } roleIDs := currentRoleIDs if roleIDsProvided { if len(attrs.RoleIDs) == 0 { if roleIDProvided { return nil, nil, nil, false, []jsonapi.ErrorObject{ contactValidationError("/data/attributes/role_ids", "role_ids cannot be empty when role_id is provided"), } } return nil, [][]byte{}, []string{}, true, nil } parsedRoleIDs := make([][]byte, 0, len(attrs.RoleIDs)) for i := range attrs.RoleIDs { id, err := uuidv7.ParseString(strings.TrimSpace(attrs.RoleIDs[i])) if err != nil { return nil, nil, nil, false, []jsonapi.ErrorObject{ contactValidationError("/data/attributes/role_ids", "role_ids contains invalid UUID"), } } parsedRoleIDs = append(parsedRoleIDs, id) } if !roleIDProvided && len(parsedRoleIDs) > 0 { // When only role_ids is provided, treat the first role as the new primary. primaryRoleID = parsedRoleIDs[0] } roleIDs = normalizeContactAssignedRoleIDs(primaryRoleID, parsedRoleIDs) } else if roleIDProvided { roleIDs = normalizeContactAssignedRoleIDs(primaryRoleID, currentRoleIDs) } if len(primaryRoleID) != 16 { if len(roleIDs) > 0 { primaryRoleID = roleIDs[0] } else { return nil, [][]byte{}, []string{}, true, nil } } if !containsContactRoleID(roleIDs, primaryRoleID) { roleIDs = normalizeContactAssignedRoleIDs(primaryRoleID, roleIDs) } roleCodes := make([]string, 0, len(roleIDs)) for i := range roleIDs { code, err := h.svc.ResolveRoleCodeByID(c.UserContext(), roleIDs[i]) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil, nil, false, []jsonapi.ErrorObject{ contactValidationError("/data/attributes/role_ids", "one or more role_ids do not exist"), } } return nil, nil, nil, false, []jsonapi.ErrorObject{ {Status: "400", Title: "Update failed", Detail: "an internal error occurred"}, } } norm := normalizeContactRoleCode(code) if !isSupportedContactRole(norm) { return nil, nil, nil, false, []jsonapi.ErrorObject{ contactValidationError("/data/attributes/role_ids", "one or more role_ids are not supported contact roles"), } } roleCodes = append(roleCodes, norm) } return primaryRoleID, roleIDs, roleCodes, true, nil } func normalizeContactAssignedRoleIDs(primaryRoleID []byte, roleIDs [][]byte) [][]byte { out := make([][]byte, 0, len(roleIDs)+1) seen := make(map[string]struct{}, len(roleIDs)+1) appendRole := func(roleID []byte) { if len(roleID) != 16 { return } key := string(roleID) if _, exists := seen[key]; exists { return } seen[key] = struct{}{} out = append(out, append([]byte(nil), roleID...)) } appendRole(primaryRoleID) for i := range roleIDs { appendRole(roleIDs[i]) } return out } func containsContactRoleID(roleIDs [][]byte, roleID []byte) bool { if len(roleID) != 16 { return false } target := string(roleID) for i := range roleIDs { if string(roleIDs[i]) == target { return true } } return false } func roleIDBytesFromRoles(item *contact.ContactListItem) ([]byte, bool) { if item == nil || item.RolesRaw == nil { return nil, false } roles := contactRoles(item.RolesRaw) if len(roles) == 0 { return nil, false } id, _ := roles[0]["id"].(string) if strings.TrimSpace(id) == "" { return nil, false } b, err := uuidv7.ParseString(strings.TrimSpace(id)) if err != nil { return nil, false } return b, true } func roleIDBytesListFromRoles(item *contact.ContactListItem) [][]byte { if item == nil || item.RolesRaw == nil { return nil } roles := contactRoles(item.RolesRaw) out := make([][]byte, 0, len(roles)) for i := range roles { id, _ := roles[i]["id"].(string) if strings.TrimSpace(id) == "" { continue } b, err := uuidv7.ParseString(strings.TrimSpace(id)) if err != nil { continue } out = append(out, b) } return out } func inferContactCreateRoleCodes(attrs dto.ContactCreateAttributes) []string { payloads := profilePayloadsFromCreate(attrs) roleCodes := make([]string, 0, len(payloads)) for i := range payloads { if !payloads[i].present { continue } roleCodes = append(roleCodes, normalizeContactRoleCode(payloads[i].roleCode)) } return roleCodes } func validateRoleSpecificPayload(expectedRoleCodes []string, requestType string, payloads []contactProfilePayload) ([]string, []jsonapi.ErrorObject) { expected := make(map[string]struct{}, len(expectedRoleCodes)) for i := range expectedRoleCodes { code := normalizeContactRoleCode(expectedRoleCodes[i]) if code == "" { continue } expected[code] = struct{}{} } requestedRoleCodes := make([]string, 0, len(payloads)) seenRequested := map[string]struct{}{} errs := make([]jsonapi.ErrorObject, 0) for i := range payloads { if !payloads[i].present { continue } code := normalizeContactRoleCode(payloads[i].roleCode) if _, ok := seenRequested[code]; !ok { seenRequested[code] = struct{}{} requestedRoleCodes = append(requestedRoleCodes, code) } } for i := range payloads { if !payloads[i].present { continue } roleCode := normalizeContactRoleCode(payloads[i].roleCode) if _, ok := expected[roleCode]; !ok { errs = append(errs, contactValidationError("/data/attributes/"+payloads[i].field, fmt.Sprintf("%s payload is not allowed for type %s", payloads[i].field, strings.TrimSpace(requestType)))) } } if len(errs) > 0 { return nil, errs } return requestedRoleCodes, nil } func assignedContactRoleCodes(item *contact.ContactListItem) []string { if item == nil { return nil } seen := map[string]struct{}{} out := make([]string, 0, 4) add := func(code string) { code = normalizeContactRoleCode(code) if code == "" { return } if _, ok := seen[code]; ok { return } seen[code] = struct{}{} out = append(out, code) } add(item.RoleCode) for _, role := range contactRoles(item.RolesRaw) { code, _ := role["role_code"].(string) add(code) } return out } func profilePayloadsFromCreate(attrs dto.ContactCreateAttributes) []contactProfilePayload { return []contactProfilePayload{ {roleCode: auth.RoleCodePilot, field: "pilot", present: attrs.Pilot != nil}, {roleCode: auth.RoleCodeDoctor, field: "doctor", present: attrs.Doctor != nil}, {roleCode: auth.RoleCodeAirRescuer, field: "air_rescuer", present: attrs.AirRescuer != nil}, {roleCode: auth.RoleCodeTechnician, field: "technician", present: attrs.Technician != nil}, {roleCode: auth.RoleCodeFlightAssistant, field: "flight_assistant", present: attrs.FlightAssistant != nil}, {roleCode: auth.RoleCodeStaff, field: "staff", present: attrs.Staff != nil}, } } func profilePayloadsFromUpdate(attrs dto.ContactUpdateAttributes) []contactProfilePayload { return []contactProfilePayload{ {roleCode: auth.RoleCodePilot, field: "pilot", present: attrs.Pilot != nil}, {roleCode: auth.RoleCodeDoctor, field: "doctor", present: attrs.Doctor != nil}, {roleCode: auth.RoleCodeAirRescuer, field: "air_rescuer", present: attrs.AirRescuer != nil}, {roleCode: auth.RoleCodeTechnician, field: "technician", present: attrs.Technician != nil}, {roleCode: auth.RoleCodeFlightAssistant, field: "flight_assistant", present: attrs.FlightAssistant != nil}, {roleCode: auth.RoleCodeStaff, field: "staff", present: attrs.Staff != nil}, } } func normalizeContactRoleCode(roleCode string) string { return strings.ToLower(strings.TrimSpace(roleCode)) } func normalizeContactRoleFilter(role string) string { v := normalizeContactRoleCode(role) switch v { case "technicians": return auth.RoleCodeTechnician default: return v } } func parseOptionalBoolQuery(value string) *bool { v := strings.ToLower(strings.TrimSpace(value)) switch v { case "", "null": return nil case "1", "true", "yes", "y": out := true return &out case "0", "false", "no", "n": out := false return &out default: return nil } } func contactCreateType() string { return "contact_create" } func contactUpdateType() string { return "contact_update" } func isSupportedContactRole(roleCode string) bool { switch normalizeContactRoleCode(roleCode) { case auth.RoleCodePilot, auth.RoleCodeDoctor, auth.RoleCodeAirRescuer, auth.RoleCodeTechnician, auth.RoleCodeFlightAssistant, auth.RoleCodeStaff: return true default: return false } } func contactValidationError(pointer string, detail string) jsonapi.ErrorObject { return jsonapi.ErrorObject{ Status: "422", Title: "Validation error", Detail: detail, Source: &jsonapi.ErrorSource{Pointer: pointer}, } } func fillPatchFromPilot(out *contact.ProfilePatch, in *dto.PilotProfileAttributes) { if out == nil || in == nil { return } out.PilotCategory = optionalStr(in.PilotCategory) out.ShortName = optionalStr(in.ShortName) out.LicenseNo = optionalStr(in.LicenseNo) out.LicenseIssuedAt = in.LicenseIssuedAt out.LicenseExpiredAt = in.LicenseExpiredAt out.TechnicianLicenseNo = optionalStr(in.TechnicianLicenseNo) out.HasIFRQualification = in.HasIFRQualification out.IsChiefPilot = in.IsChiefPilot if in.DULBaseIDs != nil { out.DULBaseIDsSet = true out.DULBaseIDs = make([][]byte, 0, len(in.DULBaseIDs)) for i := range in.DULBaseIDs { id, err := uuidv7.ParseString(strings.TrimSpace(in.DULBaseIDs[i])) if err != nil { continue } out.DULBaseIDs = append(out.DULBaseIDs, id) } } else if in.DULBase != nil { // Support FE payload shape using dul_base object array. out.DULBaseIDsSet = true out.DULBaseIDs = make([][]byte, 0, len(in.DULBase)) for i := range in.DULBase { id, err := uuidv7.ParseString(strings.TrimSpace(in.DULBase[i].ID)) if err != nil { continue } out.DULBaseIDs = append(out.DULBaseIDs, id) } } out.TotalFlightMinutes = in.TotalFlightMinutes out.ResponsiblePilotMinutes = in.ResponsiblePilotMinutes out.SecondPilotMinutes = in.SecondPilotMinutes out.DoubleCommandMinutes = in.DoubleCommandMinutes out.FlightInstructorMinutes = in.FlightInstructorMinutes out.NightFlightMinutes = in.NightFlightMinutes out.IFRFlightMinutes = in.IFRFlightMinutes out.Location = optionalStr(in.Location) out.Postcode = optionalStr(in.Postcode) out.StreetLine = optionalStr(in.StreetLine) out.Note = optionalStr(in.Note) out.WeightKG = in.WeightKG } func fillPatchFromPilotUpdate(out *contact.ProfilePatch, in *dto.PilotProfileUpdateAttributes) { if out == nil || in == nil { return } out.PilotCategory = trimStringPointer(in.PilotCategory) out.ShortName = trimStringPointer(in.ShortName) out.LicenseNo = trimStringPointer(in.LicenseNo) out.LicenseIssuedAt = in.LicenseIssuedAt out.LicenseExpiredAt = in.LicenseExpiredAt out.TechnicianLicenseNo = trimStringPointer(in.TechnicianLicenseNo) out.HasIFRQualification = in.HasIFRQualification out.IsChiefPilot = in.IsChiefPilot if in.DULBaseIDs != nil { out.DULBaseIDsSet = true out.DULBaseIDs = make([][]byte, 0, len(in.DULBaseIDs)) for i := range in.DULBaseIDs { id, err := uuidv7.ParseString(strings.TrimSpace(in.DULBaseIDs[i])) if err != nil { continue } out.DULBaseIDs = append(out.DULBaseIDs, id) } } else if in.DULBase != nil { out.DULBaseIDsSet = true out.DULBaseIDs = make([][]byte, 0, len(in.DULBase)) for i := range in.DULBase { id, err := uuidv7.ParseString(strings.TrimSpace(in.DULBase[i].ID)) if err != nil { continue } out.DULBaseIDs = append(out.DULBaseIDs, id) } } out.TotalFlightMinutes = in.TotalFlightMinutes out.ResponsiblePilotMinutes = in.ResponsiblePilotMinutes out.SecondPilotMinutes = in.SecondPilotMinutes out.DoubleCommandMinutes = in.DoubleCommandMinutes out.FlightInstructorMinutes = in.FlightInstructorMinutes out.NightFlightMinutes = in.NightFlightMinutes out.IFRFlightMinutes = in.IFRFlightMinutes out.Location = trimStringPointer(in.Location) out.Postcode = trimStringPointer(in.Postcode) out.StreetLine = trimStringPointer(in.StreetLine) out.Note = trimStringPointer(in.Note) out.WeightKG = in.WeightKG } func validatePilotDULBaseCreateInput(attrs dto.ContactCreateAttributes) []jsonapi.ErrorObject { if attrs.Pilot == nil { return nil } return validatePilotDULBaseValues(attrs.Pilot.DULBaseIDs, attrs.Pilot.DULBase) } func validatePilotDULBaseUpdateInput(attrs dto.ContactUpdateAttributes) []jsonapi.ErrorObject { if attrs.Pilot == nil { return nil } return validatePilotDULBaseValues(attrs.Pilot.DULBaseIDs, attrs.Pilot.DULBase) } func validatePilotDULBasePayload(pilot *dto.PilotProfileAttributes) []jsonapi.ErrorObject { if pilot == nil { return nil } return validatePilotDULBaseValues(pilot.DULBaseIDs, pilot.DULBase) } func validatePilotDULBaseValues(baseIDs []string, bases []dto.DULBase) []jsonapi.ErrorObject { if baseIDs != nil && bases != nil { return []jsonapi.ErrorObject{ contactValidationError("/data/attributes/pilot", "provide only one of dul_base_ids or dul_base"), } } if baseIDs != nil { for i := range baseIDs { if _, err := uuidv7.ParseString(strings.TrimSpace(baseIDs[i])); err != nil { return []jsonapi.ErrorObject{ contactValidationError("/data/attributes/pilot/dul_base_ids", "dul_base_ids contains invalid UUID"), } } } } if bases != nil { for i := range bases { if _, err := uuidv7.ParseString(strings.TrimSpace(bases[i].ID)); err != nil { return []jsonapi.ErrorObject{ contactValidationError("/data/attributes/pilot/dul_base", "dul_base contains invalid UUID"), } } } } return nil } func validatePilotDULBasePayloadUpdate(pilot *dto.PilotProfileUpdateAttributes) []jsonapi.ErrorObject { if pilot == nil { return nil } return validatePilotDULBaseValues(pilot.DULBaseIDs, pilot.DULBase) } func fillPatchFromDoctor(out *contact.ProfilePatch, in *dto.DoctorProfileAttributes) { if out == nil || in == nil { return } out.ShortName = optionalStr(in.ShortName) out.Specialization = optionalStr(in.Specialization) out.SubSpecialization = optionalStr(in.SubSpecialization) out.IsChiefPhysician = in.IsChiefPhysician out.Location = optionalStr(in.Location) out.Postcode = optionalStr(in.Postcode) out.StreetLine = optionalStr(in.StreetLine) out.Note = optionalStr(in.Note) } func fillPatchFromDoctorUpdate(out *contact.ProfilePatch, in *dto.DoctorProfileUpdateAttributes) { if out == nil || in == nil { return } out.ShortName = trimStringPointer(in.ShortName) out.Specialization = trimStringPointer(in.Specialization) out.SubSpecialization = trimStringPointer(in.SubSpecialization) out.IsChiefPhysician = in.IsChiefPhysician out.Location = trimStringPointer(in.Location) out.Postcode = trimStringPointer(in.Postcode) out.StreetLine = trimStringPointer(in.StreetLine) out.Note = trimStringPointer(in.Note) } func fillPatchFromAirRescuer(out *contact.ProfilePatch, in *dto.AirRescuerProfileAttributes) { if out == nil || in == nil { return } out.ShortName = optionalStr(in.ShortName) out.ResponsibleFlightRescuer = in.ResponsibleFlightRescuer out.Location = optionalStr(in.Location) out.Postcode = optionalStr(in.Postcode) out.StreetLine = optionalStr(in.StreetLine) out.Note = optionalStr(in.Note) } func fillPatchFromAirRescuerUpdate(out *contact.ProfilePatch, in *dto.AirRescuerProfileUpdateAttributes) { if out == nil || in == nil { return } out.ShortName = trimStringPointer(in.ShortName) out.ResponsibleFlightRescuer = in.ResponsibleFlightRescuer out.Location = trimStringPointer(in.Location) out.Postcode = trimStringPointer(in.Postcode) out.StreetLine = trimStringPointer(in.StreetLine) out.Note = trimStringPointer(in.Note) } func fillPatchFromTechnician(out *contact.ProfilePatch, in *dto.TechnicianProfileAttributes) { if out == nil || in == nil { return } out.ShortName = optionalStr(in.ShortName) out.LicenseNo = optionalStr(in.LicenseNo) out.IsChiefTechnician = in.IsChiefTechnician out.IsResponsibleComplaints = in.IsResponsibleComplaints out.Location = optionalStr(in.Location) out.Postcode = optionalStr(in.Postcode) out.StreetLine = optionalStr(in.StreetLine) out.Note = optionalStr(in.Note) } func fillPatchFromTechnicianUpdate(out *contact.ProfilePatch, in *dto.TechnicianProfileUpdateAttributes) { if out == nil || in == nil { return } out.ShortName = trimStringPointer(in.ShortName) out.LicenseNo = trimStringPointer(in.LicenseNo) out.IsChiefTechnician = in.IsChiefTechnician out.IsResponsibleComplaints = in.IsResponsibleComplaints out.Location = trimStringPointer(in.Location) out.Postcode = trimStringPointer(in.Postcode) out.StreetLine = trimStringPointer(in.StreetLine) out.Note = trimStringPointer(in.Note) } func fillPatchFromFlightAssistant(out *contact.ProfilePatch, in *dto.FlightAssistantProfileAttributes) { if out == nil || in == nil { return } out.ShortName = optionalStr(in.ShortName) out.Location = optionalStr(in.Location) out.Postcode = optionalStr(in.Postcode) out.StreetLine = optionalStr(in.StreetLine) out.Note = optionalStr(in.Note) } func fillPatchFromFlightAssistantUpdate(out *contact.ProfilePatch, in *dto.FlightAssistantProfileUpdateAttributes) { if out == nil || in == nil { return } out.ShortName = trimStringPointer(in.ShortName) out.Location = trimStringPointer(in.Location) out.Postcode = trimStringPointer(in.Postcode) out.StreetLine = trimStringPointer(in.StreetLine) out.Note = trimStringPointer(in.Note) } func fillPatchFromStaff(out *contact.ProfilePatch, in *dto.StaffProfileAttributes) { if out == nil || in == nil { return } out.ShortName = optionalStr(in.ShortName) out.RoleLabel = optionalStr(in.RoleLabel) out.Location = optionalStr(in.Location) out.Postcode = optionalStr(in.Postcode) out.StreetLine = optionalStr(in.StreetLine) out.Note = optionalStr(in.Note) } func fillPatchFromStaffUpdate(out *contact.ProfilePatch, in *dto.StaffProfileUpdateAttributes) { if out == nil || in == nil { return } out.ShortName = trimStringPointer(in.ShortName) out.RoleLabel = trimStringPointer(in.RoleLabel) out.Location = trimStringPointer(in.Location) out.Postcode = trimStringPointer(in.Postcode) out.StreetLine = trimStringPointer(in.StreetLine) out.Note = trimStringPointer(in.Note) } func optionalStr(v string) *string { t := strings.TrimSpace(v) if t == "" { return nil } return &t } func trimStringPointer(v *string) *string { if v == nil { return nil } trimmed := strings.TrimSpace(*v) return &trimmed } func strPtr(v string) *string { return &v } func emptyToNil(v string) *string { if strings.TrimSpace(v) == "" { return nil } return &v } func normalizeContactUpdateSSOEmailForValidation(req *dto.ContactUpdateRequest) bool { if req == nil { return false } if req.Data.Attributes.SSOEmail == nil { return false } trimmed := strings.TrimSpace(*req.Data.Attributes.SSOEmail) if trimmed == "" { // For validation, empty SSO email should be treated as omitted. // After validation succeeds, caller can restore explicit empty to clear DB value. req.Data.Attributes.SSOEmail = nil return true } req.Data.Attributes.SSOEmail = &trimmed return false } func contactUUIDString(id []byte) string { s, _ := uuidv7.BytesToString(id) return s } func contactAttributes(item *contact.ContactListItem, includeSSOEmail bool) map[string]any { if item == nil { return map[string]any{} } roles := contactRoles(item.RolesRaw) if roles == nil { roles = []map[string]any{} } attrs := map[string]any{ "pilot_category": item.PilotCategory, "username": item.Username, "email": item.Email, "is_admin": item.IsAdmin, "first_name": item.FirstName, "last_name": item.LastName, "short_name": item.ShortName, "license_no": item.LicenseNo, "location": item.Location, "mobile_phone": item.MobilePhone, "set_password": !item.HasPassword, "sortkey": cloneIntPointer(item.SortKey), "note": item.Note, "is_active": item.IsActive, "created_by": uuidStringOrEmpty(item.CreatedBy), "updated_by": uuidStringOrEmpty(item.UpdatedBy), "created_at": optionalTimeRFC3339(item.CreatedAt), "updated_at": optionalTimeRFC3339(item.UpdatedAt), "base_roles": contactBaseRoles(item.BaseRolesRaw), } attrs["roles"] = roles if includeSSOEmail { attrs["sso_email"] = item.SSOEmail } roleCodes := map[string]struct{}{normalizeContactRoleCode(item.RoleCode): {}} for _, role := range roles { code, _ := role["role_code"].(string) if strings.TrimSpace(code) == "" { continue } roleCodes[normalizeContactRoleCode(code)] = struct{}{} } if _, ok := roleCodes[auth.RoleCodePilot]; ok { dulBases := contactDULBases(item.BaseRolesRaw) if strings.TrimSpace(item.PilotCategory) != "" || item.PilotShortName != nil || item.PilotLicenseNo != nil || item.PilotLicenseIssuedAt != nil || item.PilotLicenseExpiredAt != nil || item.PilotTechnicianLicenseNo != nil || item.PilotHasIFRQualification != nil || item.PilotIsChiefPilot != nil || item.PilotIsDUL != nil || item.PilotTotalFlightMinutes != nil || item.PilotResponsiblePilotMinutes != nil || item.PilotSecondPilotMinutes != nil || item.PilotDoubleCommandMinutes != nil || item.PilotFlightInstructorMinutes != nil || item.PilotNightFlightMinutes != nil || item.PilotIFRFlightMinutes != nil || item.PilotLocation != nil || item.PilotPostcode != nil || item.PilotStreetLine != nil || item.PilotNote != nil || item.PilotWeightKG != nil || len(dulBases) > 0 { attrs["pilot"] = map[string]any{ "pilot_category": item.PilotCategory, "short_name": item.PilotShortName, "license_no": item.PilotLicenseNo, "license_issued_at": item.PilotLicenseIssuedAt, "license_expired_at": item.PilotLicenseExpiredAt, "technician_license_no": item.PilotTechnicianLicenseNo, "has_ifr_qualification": item.PilotHasIFRQualification, "is_chief_pilot": item.PilotIsChiefPilot, "is_dul": item.PilotIsDUL, "dul_base": dulBases, "total_flight_minutes": item.PilotTotalFlightMinutes, "responsible_pilot_minutes": item.PilotResponsiblePilotMinutes, "second_pilot_minutes": item.PilotSecondPilotMinutes, "double_command_minutes": item.PilotDoubleCommandMinutes, "flight_instructor_minutes": item.PilotFlightInstructorMinutes, "night_flight_minutes": item.PilotNightFlightMinutes, "ifr_flight_minutes": item.PilotIFRFlightMinutes, "location": item.PilotLocation, "postcode": item.PilotPostcode, "street_line": item.PilotStreetLine, "note": item.PilotNote, "weight_kg": item.PilotWeightKG, } removeContactAttributeKeys(attrs, "pilot_category", "short_name", "license_no", "location", "note") } } if _, ok := roleCodes[auth.RoleCodeDoctor]; ok { if item.DoctorShortName != nil || item.DoctorSpecialization != nil || item.DoctorSubSpecialization != nil || item.DoctorIsChiefPhysician != nil || item.DoctorLocation != nil || item.DoctorPostcode != nil || item.DoctorStreetLine != nil || item.DoctorNote != nil { attrs["doctor"] = map[string]any{ "short_name": item.DoctorShortName, "specialization": item.DoctorSpecialization, "sub_specialization": item.DoctorSubSpecialization, "is_chief_physician": item.DoctorIsChiefPhysician, "location": item.DoctorLocation, "postcode": item.DoctorPostcode, "street_line": item.DoctorStreetLine, "note": item.DoctorNote, } removeContactAttributeKeys(attrs, "short_name", "location", "note", "license_no", "pilot_category") } } if _, ok := roleCodes[auth.RoleCodeAirRescuer]; ok { if item.AirRescuerShortName != nil || item.AirRescuerResponsibleFlightRescuer != nil || item.AirRescuerLocation != nil || item.AirRescuerPostcode != nil || item.AirRescuerStreetLine != nil || item.AirRescuerNote != nil { attrs["air_rescuer"] = map[string]any{ "short_name": item.AirRescuerShortName, "responsible_flight_rescuer": item.AirRescuerResponsibleFlightRescuer, "location": item.AirRescuerLocation, "postcode": item.AirRescuerPostcode, "street_line": item.AirRescuerStreetLine, "note": item.AirRescuerNote, } removeContactAttributeKeys(attrs, "short_name", "location", "note", "license_no", "pilot_category") } } if _, ok := roleCodes[auth.RoleCodeTechnician]; ok { if item.TechnicianShortName != nil || item.TechnicianLicenseNo != nil || item.TechnicianIsChiefTechnician != nil || item.TechnicianIsResponsibleComplaints != nil || item.TechnicianLocation != nil || item.TechnicianPostcode != nil || item.TechnicianStreetLine != nil || item.TechnicianNote != nil { attrs["technician"] = map[string]any{ "short_name": item.TechnicianShortName, "license_no": item.TechnicianLicenseNo, "is_chief_technician": item.TechnicianIsChiefTechnician, "is_responsible_complaints": item.TechnicianIsResponsibleComplaints, "location": item.TechnicianLocation, "postcode": item.TechnicianPostcode, "street_line": item.TechnicianStreetLine, "note": item.TechnicianNote, } removeContactAttributeKeys(attrs, "short_name", "license_no", "location", "note", "pilot_category") } } if _, ok := roleCodes[auth.RoleCodeFlightAssistant]; ok { if item.FlightAssistantShortName != nil || item.FlightAssistantLocation != nil || item.FlightAssistantPostcode != nil || item.FlightAssistantStreetLine != nil || item.FlightAssistantNote != nil { attrs["flight_assistant"] = map[string]any{ "short_name": item.FlightAssistantShortName, "location": item.FlightAssistantLocation, "postcode": item.FlightAssistantPostcode, "street_line": item.FlightAssistantStreetLine, "note": item.FlightAssistantNote, } removeContactAttributeKeys(attrs, "short_name", "location", "note", "license_no", "pilot_category") } } if _, ok := roleCodes[auth.RoleCodeStaff]; ok { if item.StaffShortName != nil || item.StaffRoleLabel != nil || item.StaffLocation != nil || item.StaffPostcode != nil || item.StaffStreetLine != nil || item.StaffNote != nil { attrs["staff"] = map[string]any{ "short_name": item.StaffShortName, "role_label": item.StaffRoleLabel, "location": item.StaffLocation, "postcode": item.StaffPostcode, "street_line": item.StaffStreetLine, "note": item.StaffNote, } removeContactAttributeKeys(attrs, "short_name", "location", "note", "license_no", "pilot_category") } } return attrs } func removeContactAttributeKeys(attrs map[string]any, keys ...string) { for _, key := range keys { delete(attrs, key) } } func (h *ContactHandler) resolveContactProfileAttachmentID(c *fiber.Ctx, userID []byte, rawFileUUID *string) ([]byte, bool) { if rawFileUUID == nil { return nil, false } if strings.TrimSpace(*rawFileUUID) == "" { return nil, false } return resolveAttachmentIDFromInput(c, attachmentresolver.Params{ FileManager: h.fileManager, RawAttachmentID: nil, RawFileID: rawFileUUID, AttachmentField: "profile_attachment_id", FileField: "file_uuid", RequireValue: false, RefType: contactProfileAttachmentRefType, RefID: idString(userID), Category: ptrString(contactProfileAttachmentCategory), IsPrimary: true, ActorID: actorUserID(c), ValidateAttachmentExists: false, }, "Update failed") } func (h *ContactHandler) applyContactFoto(ctx context.Context, item *contact.ContactListItem, attrs map[string]any) { if item == nil || attrs == nil || len(item.ProfileAttachmentID) != 16 { return } fotoUUID := idString(item.ProfileAttachmentID) fotoDownloadURL := "" fotoThumbnailURL := "" var fotoOriginalSizeBytes *int64 if h.fileManager != nil { attachment, err := h.fileManager.GetAttachmentByID(ctx, item.ProfileAttachmentID) if err == nil && attachment != nil { fotoUUID = idString(attachment.ID) if attachment.File != nil { asset := resolveFileManagerDownloadURLs(ctx, h.storage, attachment.File) fotoDownloadURL = strings.TrimSpace(asset.OriginalURL) fotoThumbnailURL = strings.TrimSpace(asset.ThumbnailURL) fotoOriginalSizeBytes = int64Ptr(asset.OriginalSizeBytes) } } } attrs["foto"] = map[string]any{ "uuid": fotoUUID, "download_url": fotoDownloadURL, "thumbnail_download_url": fotoThumbnailURL, "original_size_bytes": fotoOriginalSizeBytes, } } func (h *ContactHandler) ensureContactProfileAttachmentSet(ctx context.Context, userID []byte) error { item, err := h.svc.GetContactByID(ctx, userID) if err != nil { return err } if item == nil || len(item.ProfileAttachmentID) != 16 { return errors.New("failed to set profile photo") } if h.fileManager != nil { attachment, err := h.fileManager.GetAttachmentByID(ctx, item.ProfileAttachmentID) if err != nil || attachment == nil { return errors.New("failed to set profile photo") } } return nil } func contactBaseRoles(raw *string) []map[string]any { if raw == nil || strings.TrimSpace(*raw) == "" { return nil } parts := strings.Split(*raw, ",") out := make([]map[string]any, 0, len(parts)) for i := range parts { fields := strings.Split(parts[i], "|") if len(fields) < 3 { continue } hexID := strings.TrimSpace(fields[0]) baseNameHex := strings.TrimSpace(fields[1]) roleCode := strings.TrimSpace(fields[2]) baseCategoryHex := "" if len(fields) >= 4 { baseCategoryHex = strings.TrimSpace(fields[3]) } if hexID == "" || roleCode == "" { continue } idBytes, err := hex.DecodeString(hexID) if err != nil { continue } baseID, err := uuidv7.BytesToString(idBytes) if err != nil { continue } baseNameBytes, err := hex.DecodeString(baseNameHex) if err != nil { continue } item := map[string]any{ "base_id": baseID, "base_name": string(baseNameBytes), "role_code": roleCode, } if baseCategoryHex != "" { if baseCategoryBytes, err := hex.DecodeString(baseCategoryHex); err == nil { item["base_category"] = string(baseCategoryBytes) } } out = append(out, item) } if len(out) == 0 { return nil } return out } func contactDULBases(raw *string) []map[string]any { roles := contactBaseRoles(raw) if len(roles) == 0 { return nil } out := make([]map[string]any, 0) for i := range roles { roleCode, _ := roles[i]["role_code"].(string) if strings.TrimSpace(strings.ToLower(roleCode)) != "dul" { continue } baseID, _ := roles[i]["base_id"].(string) baseName, _ := roles[i]["base_name"].(string) baseCategory, _ := roles[i]["base_category"].(string) if strings.TrimSpace(baseID) == "" { continue } out = append(out, map[string]any{ "id": baseID, "name": baseName, "category": baseCategory, }) } if len(out) == 0 { return nil } return out } func contactRoles(raw *string) []map[string]any { if raw == nil || strings.TrimSpace(*raw) == "" { return []map[string]any{} } parts := strings.Split(*raw, ",") out := make([]map[string]any, 0, len(parts)) for i := range parts { fields := strings.Split(parts[i], "|") if len(fields) != 3 { continue } roleIDHex := strings.TrimSpace(fields[0]) roleNameHex := strings.TrimSpace(fields[1]) roleCode := strings.TrimSpace(fields[2]) if roleIDHex == "" || roleCode == "" { continue } roleIDBytes, err := hex.DecodeString(roleIDHex) if err != nil { continue } roleID, err := uuidv7.BytesToString(roleIDBytes) if err != nil { continue } roleNameBytes, err := hex.DecodeString(roleNameHex) if err != nil { continue } out = append(out, map[string]any{ "id": roleID, "role_code": roleCode, "role_name": string(roleNameBytes), }) } if len(out) == 0 { return []map[string]any{} } return out } func intStatus(status int) string { if status == fiber.StatusNotFound { return "404" } return "400" } func optionalTimeRFC3339(v time.Time) string { if v.IsZero() { return "" } return v.UTC().Format(time.RFC3339) }