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

1963 lines
71 KiB
Go

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