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 }