Files
fm_be/internal/repository/mysql/contact_repo.go
2026-07-16 22:16:45 +07:00

1310 lines
39 KiB
Go

package mysql
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
"wucher/internal/domain/auth"
"wucher/internal/domain/base"
"wucher/internal/domain/contact"
"wucher/internal/shared/pkg/apperrors"
"wucher/internal/shared/pkg/sortkey"
)
type ContactRepository struct {
db *gorm.DB
hasBaseContactRolesTable bool
baseContactRolesHasContactID bool
baseContactRolesHasDeletedAt bool
}
const contactBaseRoleDUL = "dul"
const microsoftIdentityProvider = "microsoft"
var contactProfileTables = []string{
"pilot_profiles",
"doctor_profiles",
"air_rescuer_profiles",
"technician_profiles",
"flight_assistant_profiles",
"staff_profiles",
}
func NewContactRepository(db *gorm.DB) *ContactRepository {
schema := newSchemaCache(db)
hasBaseContactRolesTable := schema.HasTable("base_contact_roles")
return &ContactRepository{
db: db,
hasBaseContactRolesTable: hasBaseContactRolesTable,
baseContactRolesHasContactID: hasBaseContactRolesTable && schema.HasColumn("base_contact_roles", "contact_id"),
baseContactRolesHasDeletedAt: hasBaseContactRolesTable && schema.HasColumn("base_contact_roles", "deleted_at"),
}
}
func copyByteSlices(in [][]byte) [][]byte {
out := make([][]byte, 0, len(in))
for i := range in {
if len(in[i]) == 0 {
continue
}
out = append(out, append([]byte(nil), in[i]...))
}
return out
}
func (r *ContactRepository) ListContacts(ctx context.Context, filter contact.ListFilter) ([]contact.ContactListItem, int64, error) {
items := make([]contact.ContactListItem, 0)
var total int64
base := r.baseListQuery(ctx)
base = applyContactFilters(base, filter)
if err := base.Count(&total).Error; err != nil {
return nil, 0, err
}
query := base.Select(contactDetailSelectSQL())
if strings.TrimSpace(filter.Sort) != "" {
query = query.Order(filter.Sort)
} else {
for _, clause := range sortkey.ActivePositiveSortClauses("users", "is_active", "sortkey", "first_name", false) {
query = query.Order(clause)
}
}
if filter.Limit > 0 {
query = query.Limit(filter.Limit).Offset(filter.Offset)
}
if err := query.Scan(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *ContactRepository) GetContactByID(ctx context.Context, userID []byte) (*contact.ContactListItem, error) {
item := &contact.ContactListItem{}
q := r.baseListQuery(ctx).Where("users.id = ?", userID)
if err := q.Select(contactDetailSelectSQL()).Take(item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return item, nil
}
func (r *ContactRepository) IsActorAdmin(ctx context.Context) (bool, error) {
return r.isActorAdmin(ctx, r.db.WithContext(ctx))
}
func (r *ContactRepository) ResolveRoleCodeByID(ctx context.Context, roleID []byte) (string, error) {
_, roleCode, err := r.resolveRoleByID(r.db.WithContext(ctx), roleID)
if err != nil {
return "", err
}
return roleCode, nil
}
func (r *ContactRepository) CreateContact(ctx context.Context, in contact.CreateInput) ([]byte, error) {
var outID []byte
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if in.User.SSOEmail != nil {
isAdmin, err := r.isActorAdmin(ctx, tx)
if err != nil {
return err
}
if !isAdmin {
return errors.New("only admin can set sso_email")
}
}
assignedRoleIDs := make([][]byte, 0, len(in.RoleIDs))
assignedRoleCodes := make([]string, 0, len(in.RoleIDs))
switch {
case len(in.RoleIDs) > 0:
for i := range in.RoleIDs {
resolvedID, resolvedCode, err := r.resolveRoleByID(tx, in.RoleIDs[i])
if err != nil {
return err
}
assignedRoleIDs = append(assignedRoleIDs, resolvedID)
assignedRoleCodes = append(assignedRoleCodes, resolvedCode)
}
case len(in.RoleID) == 16:
resolvedID, resolvedCode, err := r.resolveRoleByID(tx, in.RoleID)
if err != nil {
return err
}
assignedRoleIDs = append(assignedRoleIDs, resolvedID)
assignedRoleCodes = append(assignedRoleCodes, resolvedCode)
case len(in.Profiles) > 0:
seen := make(map[string]struct{}, len(in.Profiles))
for i := range in.Profiles {
resolvedID, resolvedCode, err := r.resolveRoleID(tx, in.Profiles[i].RoleCode)
if err != nil {
return err
}
key := string(resolvedID)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
assignedRoleIDs = append(assignedRoleIDs, resolvedID)
assignedRoleCodes = append(assignedRoleCodes, resolvedCode)
}
default:
// Role assignment is optional for contact creation.
}
var roleID []byte
normalizedRoleCode := ""
if len(assignedRoleIDs) > 0 {
roleID = assignedRoleIDs[0]
normalizedRoleCode = assignedRoleCodes[0]
}
var adminRoleIDs [][]byte
if in.User.IsAdmin != nil && *in.User.IsAdmin {
adminRoleID, _, err := r.resolveRoleID(tx, "admin")
if err != nil {
return err
}
adminRoleIDs = append(adminRoleIDs, adminRoleID)
}
email := strings.TrimSpace(valueOrEmpty(in.User.Email))
ssoEmail := normalizedEmailPtr(in.User.SSOEmail)
firstName := strings.TrimSpace(valueOrEmpty(in.User.FirstName))
lastName := strings.TrimSpace(valueOrEmpty(in.User.LastName))
if email == "" || firstName == "" || lastName == "" {
return errors.New("email, first_name, and last_name are required")
}
if err := ensureUniqueSSOEmailTx(tx, ssoEmail, nil); err != nil {
return err
}
tz := "UTC"
if in.User.Timezone != nil && strings.TrimSpace(*in.User.Timezone) != "" {
tz = strings.TrimSpace(*in.User.Timezone)
}
isActive := true
if in.User.IsActive != nil {
isActive = *in.User.IsActive
}
username := trimmedPtr(in.User.Username)
if username != nil {
exists, err := usernameExistsTx(tx, *username, nil)
if err != nil {
return err
}
if exists {
return contact.ErrUsernameAlreadyExists
}
}
u := &auth.User{
Email: email,
SSOEmail: ssoEmail,
Username: username,
FirstName: firstName,
LastName: lastName,
MobilePhone: strings.TrimSpace(valueOrEmpty(in.User.MobilePhone)),
Timezone: tz,
SortKey: in.User.SortKey,
RoleID: roleID,
RoleIDs: append(copyByteSlices(assignedRoleIDs), adminRoleIDs...),
IsActive: isActive,
}
if in.User.ProfileAttachmentID != nil {
u.ProfileAttachmentID = append([]byte(nil), (*in.User.ProfileAttachmentID)...)
}
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
u.CreatedBy = actor
u.UpdatedBy = actor
}
if err := tx.Create(u).Error; err != nil {
return err
}
if err := tx.Model(&auth.User{}).Where("id = ?", u.ID).UpdateColumn("is_active", isActive).Error; err != nil {
return err
}
if err := replaceUserRolesTx(ctx, tx, u.ID, u.RoleID, u.RoleIDs); err != nil {
return err
}
if len(in.Profiles) > 0 {
for i := range in.Profiles {
if err := r.upsertProfileTx(ctx, tx, u.ID, in.Profiles[i].RoleCode, in.Profiles[i].Patch, true); err != nil {
return err
}
}
} else if normalizedRoleCode != "" && hasAnyProfilePatch(in.Profile) {
if err := r.upsertProfileTx(ctx, tx, u.ID, normalizedRoleCode, in.Profile, true); err != nil {
return err
}
}
outID = append([]byte(nil), u.ID...)
return nil
})
if err != nil {
return nil, err
}
return outID, nil
}
func (r *ContactRepository) UpdateContact(ctx context.Context, in contact.UpdateInput) error {
if len(in.UserID) != 16 {
return errors.New("invalid user id")
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
exists, err := r.userExistsTx(tx, in.UserID)
if err != nil {
return err
}
if !exists {
return gorm.ErrRecordNotFound
}
updates := map[string]any{}
ssoEmailUpdated := false
ssoEmailCleared := false
if in.User.SSOEmail != nil {
isAdmin, err := r.isActorAdmin(ctx, tx)
if err != nil {
return err
}
if !isAdmin {
return errors.New("only admin can update sso_email")
}
normalizedSSO := normalizedEmailPtr(in.User.SSOEmail)
if err := ensureUniqueSSOEmailTx(tx, normalizedSSO, in.UserID); err != nil {
return err
}
updates["sso_email"] = normalizedSSO
ssoEmailUpdated = true
ssoEmailCleared = normalizedSSO == nil
}
if in.User.Email != nil {
updates["email"] = strings.TrimSpace(*in.User.Email)
}
if in.User.Username != nil {
username := trimmedPtr(in.User.Username)
if username != nil {
exists, err := usernameExistsTx(tx, *username, in.UserID)
if err != nil {
return err
}
if exists {
return contact.ErrUsernameAlreadyExists
}
}
updates["username"] = username
}
if in.User.FirstName != nil {
updates["first_name"] = strings.TrimSpace(*in.User.FirstName)
}
if in.User.LastName != nil {
updates["last_name"] = strings.TrimSpace(*in.User.LastName)
}
if in.User.MobilePhone != nil {
updates["mobile_phone"] = strings.TrimSpace(*in.User.MobilePhone)
}
if in.User.Timezone != nil {
updates["timezone"] = strings.TrimSpace(*in.User.Timezone)
}
if in.User.SortKeySet {
if in.User.SortKey == nil {
updates["sortkey"] = nil
} else {
updates["sortkey"] = *in.User.SortKey
}
}
if in.User.IsActive != nil {
updates["is_active"] = *in.User.IsActive
}
if in.User.ProfileAttachmentID != nil {
updates["profile_attachment_id"] = *in.User.ProfileAttachmentID
}
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
updates["updated_by"] = actor
}
if len(updates) > 0 {
if err := tx.Table("users").Where("id = ?", in.UserID).Updates(updates).Error; err != nil {
return err
}
}
if ssoEmailUpdated && ssoEmailCleared {
if err := tx.Where("user_id = ? AND provider = ?", in.UserID, microsoftIdentityProvider).
Delete(&auth.UserIdentity{}).Error; err != nil {
return err
}
}
if in.UpdateRoles {
if len(in.RoleID) != 0 && len(in.RoleID) != 16 {
return errors.New("invalid role_id")
}
if err := tx.Table("users").Where("id = ?", in.UserID).Update("role_id", in.RoleID).Error; err != nil {
return err
}
if err := replaceUserRolesTx(ctx, tx, in.UserID, in.RoleID, in.RoleIDs); err != nil {
return err
}
}
if in.User.IsAdmin != nil {
if err := r.syncAdminRoleTx(ctx, tx, in.UserID, *in.User.IsAdmin); err != nil {
return err
}
}
if len(in.Profiles) > 0 {
for i := range in.Profiles {
if err := r.upsertProfileTx(ctx, tx, in.UserID, in.Profiles[i].RoleCode, in.Profiles[i].Patch, false); err != nil {
return err
}
}
if err := r.touchUserUpdatedByTx(ctx, tx, in.UserID); err != nil {
return err
}
} else if hasAnyProfilePatch(in.Profile) {
roleCode := strings.TrimSpace(in.TargetProfileRole)
if roleCode == "" {
resolvedRoleCode, err := r.roleCodeForUser(tx, in.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Contact can now have no role. If profile payload still comes from FE
// while no role is assigned, skip profile upsert and keep user update successful.
return nil
}
return err
}
roleCode = resolvedRoleCode
}
if err := r.upsertProfileTx(ctx, tx, in.UserID, roleCode, in.Profile, false); err != nil {
return err
}
if err := r.touchUserUpdatedByTx(ctx, tx, in.UserID); err != nil {
return err
}
}
return nil
})
}
func (r *ContactRepository) DeleteContact(ctx context.Context, userID []byte) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
exists, err := r.userExistsTx(tx, userID)
if err != nil {
return err
}
if !exists {
return gorm.ErrRecordNotFound
}
if r.hasBaseContactRolesTable && r.baseContactRolesHasContactID {
query := tx.Table("base_contact_roles").Where("contact_id = ?", userID)
if r.baseContactRolesHasDeletedAt {
query = query.Where("deleted_at IS NULL")
}
var count int64
if err := query.Count(&count).Error; err != nil {
return err
}
if count > 0 {
return apperrors.NewDeleteConflictError("bases")
}
}
if err := r.deleteContactProfilesTx(tx, userID); err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&auth.UserRole{}).Error; err != nil {
return err
}
return tx.Where("id = ?", userID).Delete(&auth.User{}).Error
})
}
func (r *ContactRepository) UpdateContactStatus(ctx context.Context, userID []byte, isActive bool) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
exists, err := r.userExistsTx(tx, userID)
if err != nil {
return err
}
if !exists {
return gorm.ErrRecordNotFound
}
updates := map[string]any{"is_active": isActive}
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
updates["updated_by"] = actor
}
return tx.Table("users").Where("id = ?", userID).Updates(updates).Error
})
}
func (r *ContactRepository) UpdateContactRole(ctx context.Context, userID []byte, roleCode string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
exists, err := r.userExistsTx(tx, userID)
if err != nil {
return err
}
if !exists {
return gorm.ErrRecordNotFound
}
currentRoleID, err := r.primaryRoleIDForUserTx(tx, userID)
if err != nil {
return err
}
roleID, normalizedRoleCode, err := r.resolveRoleID(tx, roleCode)
if err != nil {
return err
}
userUpdates := map[string]any{"role_id": roleID}
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
userUpdates["updated_by"] = actor
}
if err := tx.Table("users").Where("id = ?", userID).Updates(userUpdates).Error; err != nil {
return err
}
if err := replacePrimaryUserRoleTx(ctx, tx, userID, currentRoleID, roleID); err != nil {
return err
}
// Ensure profile row exists for the new role.
return r.upsertProfileTx(ctx, tx, userID, normalizedRoleCode, contact.ProfilePatch{}, false)
})
}
func (r *ContactRepository) userExistsTx(tx *gorm.DB, userID []byte) (bool, error) {
var count int64
if err := tx.Table("users").Where("id = ?", userID).Limit(1).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (r *ContactRepository) deleteContactProfilesTx(tx *gorm.DB, userID []byte) error {
for i := range contactProfileTables {
if err := tx.Exec("DELETE FROM "+contactProfileTables[i]+" WHERE user_id = ?", userID).Error; err != nil {
return err
}
}
return nil
}
func (r *ContactRepository) UpdatePilotCategory(ctx context.Context, userID []byte, category string) error {
category = strings.ToLower(strings.TrimSpace(category))
if category == "" {
return errors.New("pilot_category is required")
}
patch := contact.ProfilePatch{PilotCategory: &category}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := r.upsertProfileTx(ctx, tx, userID, auth.RoleCodePilot, patch, false); err != nil {
return err
}
return r.touchUserUpdatedByTx(ctx, tx, userID)
})
}
func (r *ContactRepository) UpdateLicense(ctx context.Context, userID []byte, licenseNo *string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
roleCode, err := r.roleCodeForUser(tx, userID)
if err != nil {
return err
}
patch := contact.ProfilePatch{LicenseNo: licenseNo}
if err := r.upsertProfileTx(ctx, tx, userID, roleCode, patch, false); err != nil {
return err
}
return r.touchUserUpdatedByTx(ctx, tx, userID)
})
}
func (r *ContactRepository) UpdateChiefFlags(ctx context.Context, userID []byte, patch contact.ProfilePatch) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
roleCode, err := r.roleCodeForUser(tx, userID)
if err != nil {
return err
}
if err := r.upsertProfileTx(ctx, tx, userID, roleCode, patch, false); err != nil {
return err
}
return r.touchUserUpdatedByTx(ctx, tx, userID)
})
}
func (r *ContactRepository) UpdateResponsibleComplaints(ctx context.Context, userID []byte, value bool) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
patch := contact.ProfilePatch{IsResponsibleComplaints: &value}
if err := r.upsertProfileTx(ctx, tx, userID, auth.RoleCodeTechnician, patch, false); err != nil {
return err
}
return r.touchUserUpdatedByTx(ctx, tx, userID)
})
}
func (r *ContactRepository) BulkUpdateStatus(ctx context.Context, userIDs [][]byte, isActive bool) (int64, error) {
if len(userIDs) == 0 {
return 0, nil
}
updates := map[string]any{"is_active": isActive}
if actor := actorUserIDFromContext(ctx); len(actor) > 0 {
updates["updated_by"] = actor
}
res := r.db.WithContext(ctx).Table("users").Where("id IN ?", userIDs).Updates(updates)
if res.Error != nil {
return 0, res.Error
}
return res.RowsAffected, nil
}
func (r *ContactRepository) touchUserUpdatedByTx(ctx context.Context, tx *gorm.DB, userID []byte) error {
actor := actorUserIDFromContext(ctx)
if len(actor) == 0 {
return nil
}
return tx.Table("users").Where("id = ?", userID).Update("updated_by", actor).Error
}
func (r *ContactRepository) baseListQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).
Table("users").
Joins("LEFT JOIN roles ON roles.id = users.role_id").
Joins("LEFT JOIN pilot_profiles pp ON pp.user_id = users.id").
Joins("LEFT JOIN doctor_profiles dp ON dp.user_id = users.id").
Joins("LEFT JOIN air_rescuer_profiles arp ON arp.user_id = users.id").
Joins("LEFT JOIN technician_profiles tp ON tp.user_id = users.id").
Joins("LEFT JOIN flight_assistant_profiles fap ON fap.user_id = users.id").
Joins("LEFT JOIN staff_profiles sp ON sp.user_id = users.id")
}
func (r *ContactRepository) isActorAdmin(ctx context.Context, tx *gorm.DB) (bool, error) {
actor := actorUserIDFromContext(ctx)
if len(actor) != 16 {
return false, nil
}
var count int64
err := tx.Table("users AS actor_users").
Joins("LEFT JOIN user_roles ur ON ur.user_id = actor_users.id").
Joins("LEFT JOIN roles role_primary ON role_primary.id = actor_users.role_id").
Joins("LEFT JOIN roles role_assigned ON role_assigned.id = ur.role_id").
Where("actor_users.id = ?", actor).
Where(`(
LOWER(COALESCE(NULLIF(role_primary.code, ''), role_primary.name)) = ? OR
LOWER(COALESCE(NULLIF(role_assigned.code, ''), role_assigned.name)) = ?
)`, "admin", "admin").
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func applyContactFilters(q *gorm.DB, filter contact.ListFilter) *gorm.DB {
role := strings.ToLower(strings.TrimSpace(filter.Role))
if role != "" {
q = q.Where(`(
LOWER(COALESCE(NULLIF(roles.code, ''), roles.name)) = ?
OR
EXISTS (
SELECT 1
FROM user_roles ur_filter
JOIN roles r_filter ON r_filter.id = ur_filter.role_id
WHERE ur_filter.user_id = users.id
AND LOWER(COALESCE(NULLIF(r_filter.code, ''), r_filter.name)) = ?
)
)`, role, role)
}
pilotCategory := strings.ToLower(strings.TrimSpace(filter.PilotCategory))
if pilotCategory != "" {
q = q.Where(`(
LOWER(COALESCE(NULLIF(roles.code, ''), roles.name)) = ?
OR
EXISTS (
SELECT 1
FROM user_roles ur_pilot
JOIN roles r_pilot ON r_pilot.id = ur_pilot.role_id
WHERE ur_pilot.user_id = users.id
AND LOWER(COALESCE(NULLIF(r_pilot.code, ''), r_pilot.name)) = ?
)
)`, auth.RoleCodePilot, auth.RoleCodePilot).Where("pp.pilot_category = ?", pilotCategory)
}
if filter.ChiefDoctor != nil {
q = q.Where("COALESCE(dp.is_chief_physician, 0) = ?", *filter.ChiefDoctor)
}
if filter.ChiefTechnician != nil {
q = q.Where("COALESCE(tp.is_chief_technician, 0) = ?", *filter.ChiefTechnician)
}
if filter.ResponsibleComplaint != nil {
q = q.Where("COALESCE(tp.is_responsible_complaints, 0) = ?", *filter.ResponsibleComplaint)
}
if filter.ChiefPilot != nil {
q = q.Where("COALESCE(pp.is_chief_pilot, 0) = ?", *filter.ChiefPilot)
}
if filter.IFRQualified != nil {
q = q.Where("COALESCE(pp.has_ifr_qualification, 0) = ?", *filter.IFRQualified)
}
search := strings.TrimSpace(filter.Search)
if search != "" {
like := "%" + search + "%"
q = q.Where(`
users.username LIKE ? OR
users.email LIKE ? OR
users.first_name LIKE ? OR
users.last_name LIKE ? OR
pp.note LIKE ? OR
dp.note LIKE ? OR
arp.note LIKE ? OR
tp.note LIKE ? OR
fap.note LIKE ? OR
sp.note LIKE ? OR
pp.short_name LIKE ? OR
dp.short_name LIKE ? OR
arp.short_name LIKE ? OR
tp.short_name LIKE ? OR
fap.short_name LIKE ? OR
sp.short_name LIKE ?
`, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like, like)
}
return q
}
func contactListSelectSQL() string {
return `
users.id AS user_id,
users.created_by AS created_by,
users.updated_by AS updated_by,
COALESCE(NULLIF(roles.code, ''), roles.name) AS role_code,
roles.name AS role_name,
pp.pilot_category AS pilot_category,
users.username AS username,
users.email AS email,
users.sso_email AS sso_email,
EXISTS (
SELECT 1
FROM user_roles ur_admin
JOIN roles r_admin ON r_admin.id = ur_admin.role_id
WHERE ur_admin.user_id = users.id
AND LOWER(COALESCE(NULLIF(r_admin.code, ''), r_admin.name)) = 'admin'
) AS is_admin,
users.first_name AS first_name,
users.last_name AS last_name,
COALESCE(pp.short_name, dp.short_name, arp.short_name, tp.short_name, fap.short_name, sp.short_name) AS short_name,
COALESCE(pp.license_no, tp.license_no) AS license_no,
COALESCE(pp.location, dp.location, arp.location, tp.location, fap.location, sp.location) AS location,
users.mobile_phone AS mobile_phone,
users.profile_attachment_id AS profile_attachment_id,
(COALESCE(OCTET_LENGTH(users.password_hash), 0) > 0) AS has_password,
users.sortkey AS sortkey,
COALESCE(pp.note, dp.note, arp.note, tp.note, fap.note, sp.note, '') AS note,
users.is_active AS is_active,
users.created_at AS created_at,
users.updated_at AS updated_at
`
}
func contactDetailSelectSQL() string {
return `
users.id AS user_id,
users.created_by AS created_by,
users.updated_by AS updated_by,
COALESCE(NULLIF(roles.code, ''), roles.name) AS role_code,
roles.name AS role_name,
pp.pilot_category AS pilot_category,
users.username AS username,
users.email AS email,
users.sso_email AS sso_email,
EXISTS (
SELECT 1
FROM user_roles ur_admin
JOIN roles r_admin ON r_admin.id = ur_admin.role_id
WHERE ur_admin.user_id = users.id
AND LOWER(COALESCE(NULLIF(r_admin.code, ''), r_admin.name)) = 'admin'
) AS is_admin,
users.first_name AS first_name,
users.last_name AS last_name,
COALESCE(pp.short_name, dp.short_name, arp.short_name, tp.short_name, fap.short_name, sp.short_name) AS short_name,
COALESCE(pp.license_no, tp.license_no) AS license_no,
COALESCE(pp.location, dp.location, arp.location, tp.location, fap.location, sp.location) AS location,
users.mobile_phone AS mobile_phone,
users.profile_attachment_id AS profile_attachment_id,
(COALESCE(OCTET_LENGTH(users.password_hash), 0) > 0) AS has_password,
users.sortkey AS sortkey,
COALESCE(pp.note, dp.note, arp.note, tp.note, fap.note, sp.note, '') AS note,
(
SELECT GROUP_CONCAT(CONCAT(LOWER(HEX(bcr.base_id)), '|', LOWER(HEX(b.base)), '|', bcr.role_code, '|', LOWER(HEX(COALESCE(bc.name, '')))))
FROM base_contact_roles bcr
JOIN bases b ON b.id = bcr.base_id AND b.deleted_at IS NULL
LEFT JOIN base_categories bc ON bc.id = b.base_category_id
WHERE bcr.contact_id = users.id
) AS base_roles_raw,
(
SELECT GROUP_CONCAT(CONCAT(LOWER(HEX(r_all.id)), '|', LOWER(HEX(r_all.name)), '|', LOWER(COALESCE(NULLIF(r_all.code, ''), r_all.name))))
FROM user_roles ur_all
JOIN roles r_all ON r_all.id = ur_all.role_id
WHERE ur_all.user_id = users.id
) AS roles_raw,
users.is_active AS is_active,
users.created_at AS created_at,
users.updated_at AS updated_at,
pp.short_name AS pilot_short_name,
pp.license_no AS pilot_license_no,
pp.license_issued_at AS pilot_license_issued_at,
pp.license_expired_at AS pilot_license_expired_at,
pp.technician_license_no AS pilot_technician_license_no,
pp.has_ifr_qualification AS pilot_has_ifr_qualification,
pp.is_chief_pilot AS pilot_is_chief_pilot,
(
EXISTS (
SELECT 1
FROM base_contact_roles bcr_dul
JOIN bases b_dul ON b_dul.id = bcr_dul.base_id AND b_dul.deleted_at IS NULL
WHERE bcr_dul.contact_id = users.id
AND LOWER(bcr_dul.role_code) = 'dul'
AND b_dul.dul = 1
)
) AS pilot_is_dul,
pp.total_flight_minutes AS pilot_total_flight_minutes,
pp.responsible_pilot_minutes AS pilot_responsible_pilot_minutes,
pp.second_pilot_minutes AS pilot_second_pilot_minutes,
pp.double_command_minutes AS pilot_double_command_minutes,
pp.flight_instructor_minutes AS pilot_flight_instructor_minutes,
pp.night_flight_minutes AS pilot_night_flight_minutes,
pp.ifr_flight_minutes AS pilot_ifr_flight_minutes,
pp.location AS pilot_location,
pp.postcode AS pilot_postcode,
pp.street_line AS pilot_street_line,
pp.note AS pilot_note,
pp.weight_kg AS pilot_weight_kg,
dp.short_name AS doctor_short_name,
dp.specialization AS doctor_specialization,
dp.sub_specialization AS doctor_sub_specialization,
dp.is_chief_physician AS doctor_is_chief_physician,
dp.location AS doctor_location,
dp.postcode AS doctor_postcode,
dp.street_line AS doctor_street_line,
dp.note AS doctor_note,
arp.short_name AS air_rescuer_short_name,
arp.responsible_flight_rescuer AS air_rescuer_responsible_flight_rescuer,
arp.location AS air_rescuer_location,
arp.postcode AS air_rescuer_postcode,
arp.street_line AS air_rescuer_street_line,
arp.note AS air_rescuer_note,
tp.short_name AS technician_short_name,
tp.license_no AS technician_license_no,
tp.is_chief_technician AS technician_is_chief_technician,
tp.is_responsible_complaints AS technician_is_responsible_complaints,
tp.location AS technician_location,
tp.postcode AS technician_postcode,
tp.street_line AS technician_street_line,
tp.note AS technician_note,
fap.short_name AS flight_assistant_short_name,
fap.location AS flight_assistant_location,
fap.postcode AS flight_assistant_postcode,
fap.street_line AS flight_assistant_street_line,
fap.note AS flight_assistant_note,
sp.short_name AS staff_short_name,
sp.role_label AS staff_role_label,
sp.location AS staff_location,
sp.postcode AS staff_postcode,
sp.street_line AS staff_street_line,
sp.note AS staff_note
`
}
func (r *ContactRepository) roleCodeForUser(tx *gorm.DB, userID []byte) (string, error) {
var out struct{ RoleCode string }
err := tx.Table("users").
Select("LOWER(COALESCE(NULLIF(roles.code, ''), roles.name)) AS role_code").
Joins("JOIN roles ON roles.id = users.role_id").
Where("users.id = ?", userID).
Take(&out).Error
if err != nil {
return "", err
}
return strings.TrimSpace(out.RoleCode), nil
}
func (r *ContactRepository) resolveRoleID(tx *gorm.DB, roleCode string) ([]byte, string, error) {
roleCode = strings.ToLower(strings.TrimSpace(roleCode))
if roleCode == "" {
return nil, "", errors.New("role is required")
}
var role auth.Role
err := tx.Where("LOWER(code) = ? OR LOWER(name) = ?", roleCode, roleCode).Take(&role).Error
if err != nil {
return nil, "", err
}
normalized := strings.ToLower(strings.TrimSpace(role.Code))
if normalized == "" {
normalized = strings.ToLower(strings.TrimSpace(role.Name))
}
return role.ID, normalized, nil
}
func (r *ContactRepository) resolveRoleByID(tx *gorm.DB, roleID []byte) ([]byte, string, error) {
if len(roleID) != 16 {
return nil, "", errors.New("role_id is invalid")
}
var role auth.Role
if err := tx.Where("id = ?", roleID).Take(&role).Error; err != nil {
return nil, "", err
}
normalized := strings.ToLower(strings.TrimSpace(role.Code))
if normalized == "" {
normalized = strings.ToLower(strings.TrimSpace(role.Name))
}
return role.ID, normalized, nil
}
func (r *ContactRepository) upsertProfileTx(ctx context.Context, tx *gorm.DB, userID []byte, roleCode string, patch contact.ProfilePatch, isCreate bool) error {
now := time.Now().UTC()
actor := actorUserIDFromContext(ctx)
switch strings.ToLower(strings.TrimSpace(roleCode)) {
case auth.RoleCodePilot:
updates := map[string]any{}
if patch.PilotCategory != nil {
updates["pilot_category"] = strings.ToLower(strings.TrimSpace(*patch.PilotCategory))
}
if v := patch.ShortName; v != nil {
updates["short_name"] = strings.TrimSpace(*v)
}
if v := patch.LicenseNo; v != nil {
updates["license_no"] = strings.TrimSpace(*v)
}
if v := patch.LicenseIssuedAt; v != nil {
updates["license_issued_at"] = v
}
if v := patch.LicenseExpiredAt; v != nil {
updates["license_expired_at"] = v
}
if v := patch.TechnicianLicenseNo; v != nil {
updates["technician_license_no"] = strings.TrimSpace(*v)
}
if v := patch.HasIFRQualification; v != nil {
updates["has_ifr_qualification"] = *v
}
if v := patch.IsChiefPilot; v != nil {
updates["is_chief_pilot"] = *v
}
if v := patch.TotalFlightMinutes; v != nil {
updates["total_flight_minutes"] = *v
}
if v := patch.ResponsiblePilotMinutes; v != nil {
updates["responsible_pilot_minutes"] = *v
}
if v := patch.SecondPilotMinutes; v != nil {
updates["second_pilot_minutes"] = *v
}
if v := patch.DoubleCommandMinutes; v != nil {
updates["double_command_minutes"] = *v
}
if v := patch.FlightInstructorMinutes; v != nil {
updates["flight_instructor_minutes"] = *v
}
if v := patch.NightFlightMinutes; v != nil {
updates["night_flight_minutes"] = *v
}
if v := patch.IFRFlightMinutes; v != nil {
updates["ifr_flight_minutes"] = *v
}
if v := patch.Location; v != nil {
updates["location"] = strings.TrimSpace(*v)
}
if v := patch.Postcode; v != nil {
updates["postcode"] = strings.TrimSpace(*v)
}
if v := patch.StreetLine; v != nil {
updates["street_line"] = strings.TrimSpace(*v)
}
if v := patch.Note; v != nil {
updates["note"] = strings.TrimSpace(*v)
}
if v := patch.WeightKG; v != nil {
updates["weight_kg"] = *v
}
if isCreate && patch.PilotCategory == nil {
updates["pilot_category"] = auth.PilotCategoryRegular
}
if err := r.upsertByUserID(tx, "pilot_profiles", userID, updates, actor, now); err != nil {
return err
}
if patch.DULBaseIDsSet {
if err := r.syncContactDULBasesTx(ctx, tx, userID, patch.DULBaseIDs); err != nil {
return err
}
}
return nil
case auth.RoleCodeDoctor:
updates := map[string]any{}
if v := patch.ShortName; v != nil {
updates["short_name"] = strings.TrimSpace(*v)
}
if v := patch.Specialization; v != nil {
updates["specialization"] = strings.TrimSpace(*v)
}
if v := patch.SubSpecialization; v != nil {
updates["sub_specialization"] = strings.TrimSpace(*v)
}
if v := patch.IsChiefPhysician; v != nil {
updates["is_chief_physician"] = *v
}
if v := patch.Location; v != nil {
updates["location"] = strings.TrimSpace(*v)
}
if v := patch.Postcode; v != nil {
updates["postcode"] = strings.TrimSpace(*v)
}
if v := patch.StreetLine; v != nil {
updates["street_line"] = strings.TrimSpace(*v)
}
if v := patch.Note; v != nil {
updates["note"] = strings.TrimSpace(*v)
}
return r.upsertByUserID(tx, "doctor_profiles", userID, updates, actor, now)
case auth.RoleCodeAirRescuer:
updates := map[string]any{}
if v := patch.ShortName; v != nil {
updates["short_name"] = strings.TrimSpace(*v)
}
if v := patch.ResponsibleFlightRescuer; v != nil {
updates["responsible_flight_rescuer"] = *v
}
if v := patch.Location; v != nil {
updates["location"] = strings.TrimSpace(*v)
}
if v := patch.Postcode; v != nil {
updates["postcode"] = strings.TrimSpace(*v)
}
if v := patch.StreetLine; v != nil {
updates["street_line"] = strings.TrimSpace(*v)
}
if v := patch.Note; v != nil {
updates["note"] = strings.TrimSpace(*v)
}
return r.upsertByUserID(tx, "air_rescuer_profiles", userID, updates, actor, now)
case auth.RoleCodeTechnician:
updates := map[string]any{}
if v := patch.ShortName; v != nil {
updates["short_name"] = strings.TrimSpace(*v)
}
if v := patch.LicenseNo; v != nil {
updates["license_no"] = strings.TrimSpace(*v)
}
if v := patch.IsChiefTechnician; v != nil {
updates["is_chief_technician"] = *v
}
if v := patch.IsResponsibleComplaints; v != nil {
updates["is_responsible_complaints"] = *v
}
if v := patch.Location; v != nil {
updates["location"] = strings.TrimSpace(*v)
}
if v := patch.Postcode; v != nil {
updates["postcode"] = strings.TrimSpace(*v)
}
if v := patch.StreetLine; v != nil {
updates["street_line"] = strings.TrimSpace(*v)
}
if v := patch.Note; v != nil {
updates["note"] = strings.TrimSpace(*v)
}
return r.upsertByUserID(tx, "technician_profiles", userID, updates, actor, now)
case auth.RoleCodeFlightAssistant:
updates := map[string]any{}
if v := patch.ShortName; v != nil {
updates["short_name"] = strings.TrimSpace(*v)
}
if v := patch.Location; v != nil {
updates["location"] = strings.TrimSpace(*v)
}
if v := patch.Postcode; v != nil {
updates["postcode"] = strings.TrimSpace(*v)
}
if v := patch.StreetLine; v != nil {
updates["street_line"] = strings.TrimSpace(*v)
}
if v := patch.Note; v != nil {
updates["note"] = strings.TrimSpace(*v)
}
return r.upsertByUserID(tx, "flight_assistant_profiles", userID, updates, actor, now)
case auth.RoleCodeStaff:
updates := map[string]any{}
if v := patch.ShortName; v != nil {
updates["short_name"] = strings.TrimSpace(*v)
}
if v := patch.RoleLabel; v != nil {
updates["role_label"] = strings.TrimSpace(*v)
}
if v := patch.Location; v != nil {
updates["location"] = strings.TrimSpace(*v)
}
if v := patch.Postcode; v != nil {
updates["postcode"] = strings.TrimSpace(*v)
}
if v := patch.StreetLine; v != nil {
updates["street_line"] = strings.TrimSpace(*v)
}
if v := patch.Note; v != nil {
updates["note"] = strings.TrimSpace(*v)
}
return r.upsertByUserID(tx, "staff_profiles", userID, updates, actor, now)
}
return nil
}
func (r *ContactRepository) upsertByUserID(tx *gorm.DB, table string, userID []byte, updates map[string]any, actor []byte, now time.Time) error {
if updates == nil {
updates = map[string]any{}
}
exists, err := r.profileExistsTx(tx, table, userID)
if err != nil {
return err
}
updates["updated_at"] = now
if len(actor) > 0 {
updates["updated_by"] = actor
}
if exists {
return tx.Table(table).Where("user_id = ?", userID).Updates(updates).Error
}
insert := map[string]any{"user_id": userID, "created_at": now, "updated_at": now}
if len(actor) > 0 {
insert["created_by"] = actor
insert["updated_by"] = actor
}
for k, v := range updates {
if k == "updated_at" || k == "updated_by" {
continue
}
insert[k] = v
}
return tx.Table(table).Create(insert).Error
}
func (r *ContactRepository) profileExistsTx(tx *gorm.DB, table string, userID []byte) (bool, error) {
var count int64
if err := tx.Table(table).Where("user_id = ?", userID).Limit(1).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func valueOrEmpty(v *string) string {
if v == nil {
return ""
}
return *v
}
func trimmedPtr(v *string) *string {
trimmed := strings.TrimSpace(valueOrEmpty(v))
if trimmed == "" {
return nil
}
return &trimmed
}
func normalizedEmailPtr(v *string) *string {
trimmed := strings.ToLower(strings.TrimSpace(valueOrEmpty(v)))
if trimmed == "" {
return nil
}
return &trimmed
}
func ensureUniqueSSOEmailTx(tx *gorm.DB, ssoEmail *string, currentUserID []byte) error {
if ssoEmail == nil {
return nil
}
q := tx.Table("users").Where("sso_email = ?", *ssoEmail)
if len(currentUserID) == 16 {
q = q.Where("id <> ?", currentUserID)
}
var count int64
if err := q.Limit(1).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("sso_email already exists")
}
return nil
}
func usernameExistsTx(tx *gorm.DB, username string, currentUserID []byte) (bool, error) {
username = strings.TrimSpace(username)
if username == "" {
return false, nil
}
q := tx.Table("users").Select("id").Where("username = ?", username)
if len(currentUserID) == 16 {
q = q.Where("id <> ?", currentUserID)
}
var row struct {
ID []byte `gorm:"column:id"`
}
err := q.Limit(1).Take(&row).Error
switch {
case err == nil:
return true, nil
case errors.Is(err, gorm.ErrRecordNotFound):
return false, nil
default:
return false, err
}
}
func (r *ContactRepository) syncAdminRoleTx(ctx context.Context, tx *gorm.DB, userID []byte, wantAdmin bool) error {
if len(userID) != 16 {
return errors.New("invalid user id")
}
primaryRoleID, err := r.primaryRoleIDForUserTx(tx, userID)
if err != nil {
return err
}
roleIDs, err := listUserRoleIDsTx(tx, userID)
if err != nil {
return err
}
adminRoleID, _, err := r.resolveRoleID(tx, "admin")
if err != nil {
return err
}
adminKey := string(adminRoleID)
adjusted := make([][]byte, 0, len(roleIDs)+1)
for i := range roleIDs {
if len(roleIDs[i]) != 16 {
continue
}
if string(roleIDs[i]) == adminKey {
continue
}
adjusted = append(adjusted, roleIDs[i])
}
if wantAdmin {
adjusted = append(adjusted, adminRoleID)
}
return replaceUserRolesTx(ctx, tx, userID, primaryRoleID, adjusted)
}
func (r *ContactRepository) primaryRoleIDForUserTx(tx *gorm.DB, userID []byte) ([]byte, error) {
var row struct {
RoleID []byte `gorm:"column:role_id"`
}
if err := tx.Table("users").Select("role_id").Where("id = ?", userID).Take(&row).Error; err != nil {
return nil, err
}
if len(row.RoleID) != 16 {
return nil, nil
}
return row.RoleID, nil
}
func hasAnyProfilePatch(p contact.ProfilePatch) bool {
return p.PilotCategory != nil ||
p.ShortName != nil ||
p.LicenseNo != nil ||
p.LicenseIssuedAt != nil ||
p.LicenseExpiredAt != nil ||
p.TechnicianLicenseNo != nil ||
p.HasIFRQualification != nil ||
p.IsChiefPilot != nil ||
p.IsDUL != nil ||
p.DULBaseIDsSet ||
p.ResponsibleFlightRescuer != nil ||
p.TotalFlightMinutes != nil ||
p.ResponsiblePilotMinutes != nil ||
p.SecondPilotMinutes != nil ||
p.DoubleCommandMinutes != nil ||
p.FlightInstructorMinutes != nil ||
p.NightFlightMinutes != nil ||
p.IFRFlightMinutes != nil ||
p.Specialization != nil ||
p.SubSpecialization != nil ||
p.IsChiefPhysician != nil ||
p.IsChiefTechnician != nil ||
p.IsResponsibleComplaints != nil ||
p.RoleLabel != nil ||
p.Note != nil ||
p.Location != nil ||
p.Postcode != nil ||
p.StreetLine != nil ||
p.WeightKG != nil
}
func (r *ContactRepository) syncContactDULBasesTx(ctx context.Context, tx *gorm.DB, userID []byte, baseIDs [][]byte) error {
if len(userID) != 16 {
return errors.New("invalid user id")
}
if err := tx.Where("contact_id = ? AND LOWER(role_code) = ?", userID, contactBaseRoleDUL).Delete(&base.BaseContactRole{}).Error; err != nil {
return err
}
if len(baseIDs) == 0 {
return nil
}
validIDs := dedupeBinaryIDs(baseIDs)
if err := ensureDULBasesTx(tx, validIDs); err != nil {
return err
}
rows := make([]base.BaseContactRole, 0, len(validIDs))
actor := actorUserIDFromContext(ctx)
for i := range validIDs {
rows = append(rows, base.BaseContactRole{
BaseID: validIDs[i],
ContactID: userID,
RoleCode: contactBaseRoleDUL,
CreatedBy: actor,
UpdatedBy: actor,
})
}
return tx.Create(&rows).Error
}
func dedupeBinaryIDs(ids [][]byte) [][]byte {
out := make([][]byte, 0, len(ids))
seen := map[string]struct{}{}
for i := range ids {
if len(ids[i]) != 16 {
continue
}
key := string(ids[i])
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
copyID := make([]byte, 16)
copy(copyID, ids[i])
out = append(out, copyID)
}
return out
}
func ensureDULBasesTx(tx *gorm.DB, ids [][]byte) error {
if len(ids) == 0 {
return nil
}
var count int64
if err := tx.Table("bases").Where("id IN ? AND deleted_at IS NULL AND dul = 1", ids).Count(&count).Error; err != nil {
return err
}
if int(count) != len(ids) {
return errors.New("all dul_base_ids must reference existing base with dul=true")
}
return nil
}