1963 lines
71 KiB
Go
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)
|
|
}
|