package countryregion import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/roomclient" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/repository" ) const CountryCodeRenameJobType = "country-code-rename" var errCountryRenameJobConflict = errors.New("country code rename job already exists") type countryCodeRenameJobStore interface { CreateJob(job *model.AdminJob) error ListActiveJobsByType(jobType string) ([]model.AdminJob, error) RenameCountryCodeAdminReferences(appCode string, oldCountryCode string, newCountryCode string, nowMS int64) (repository.CountryCodeAdminReferenceCounts, error) } type countryCodeRoomClient interface { RenameOwnerCountryCode(ctx context.Context, req roomclient.RenameOwnerCountryCodeRequest) (roomclient.RenameOwnerCountryCodeResult, error) } type countryCodeRenameResponse struct { JobID uint `json:"jobId"` Status string `json:"status"` CountryID int64 `json:"countryId"` OldCountryCode string `json:"oldCountryCode"` NewCountryCode string `json:"newCountryCode"` } type countryCodeRenameJobPayload struct { AppCode string `json:"appCode"` CountryID int64 `json:"countryId"` OldCountryCode string `json:"oldCountryCode"` NewCountryCode string `json:"newCountryCode"` CountryName string `json:"countryName"` CountryDisplayName string `json:"countryDisplayName"` SortOrder int32 `json:"sortOrder"` ISOAlpha3 string `json:"isoAlpha3"` ISONumeric string `json:"isoNumeric"` PhoneCountryCode string `json:"phoneCountryCode"` Flag string `json:"flag"` ActorID int64 `json:"actorId"` ActorName string `json:"actorName"` RequestID string `json:"requestId"` } type countryCodeRenameResult struct { CountryID int64 `json:"countryId"` OldCountryCode string `json:"oldCountryCode"` NewCountryCode string `json:"newCountryCode"` Users int64 `json:"users"` RegionMappings int64 `json:"regionMappings"` RebuildTasks int64 `json:"rebuildTasks"` Banners int64 `json:"banners"` SplashScreens int64 `json:"splashScreens"` RoomListEntries int64 `json:"roomListEntries"` RoomSnapshots int64 `json:"roomSnapshots"` CompletedAtMs int64 `json:"completedAtMs"` } type countryCodeRenameUserCounts struct { Users int64 RegionMappings int64 RebuildTasks int64 } func (s *Service) RenameCountryCode(ctx context.Context, actorID int64, actorName string, countryID int64, requestID string, req renameCountryCodeRequest) (*countryCodeRenameResponse, error) { if s.userDB == nil { return nil, errors.New("user db is not configured") } if s.jobStore == nil { return nil, errors.New("job store is not configured") } req = normalizeRenameCountryCodeRequest(req) if countryID <= 0 || strings.TrimSpace(requestID) == "" || !validCountryRenameRequest(req) { return nil, errCountryInvalid } tx, err := s.userDB.BeginTx(ctx, nil) if err != nil { return nil, err } defer tx.Rollback() country, err := queryCountryForUpdate(ctx, tx, countryID) if err != nil { return nil, err } oldCountryCode := strings.ToUpper(strings.TrimSpace(country.CountryCode)) if oldCountryCode == req.CountryCode { return nil, errCountryInvalid } if err := ensureCountryCodeAvailable(ctx, tx, countryID, req.CountryCode); err != nil { return nil, err } if err := s.ensureNoActiveCountryCodeRenameJob(ctx, appctx.FromContext(ctx), countryID, oldCountryCode, req.CountryCode); err != nil { return nil, err } payload := countryCodeRenameJobPayload{ AppCode: appctx.FromContext(ctx), CountryID: countryID, OldCountryCode: oldCountryCode, NewCountryCode: req.CountryCode, CountryName: req.CountryName, CountryDisplayName: req.CountryDisplayName, SortOrder: req.SortOrder, ISOAlpha3: req.ISOAlpha3, ISONumeric: req.ISONumeric, PhoneCountryCode: req.PhoneCountryCode, Flag: req.Flag, ActorID: actorID, ActorName: strings.TrimSpace(actorName), RequestID: strings.TrimSpace(requestID), } body, err := json.Marshal(payload) if err != nil { return nil, err } job := model.AdminJob{ Type: CountryCodeRenameJobType, Status: model.JobStatusPending, PayloadJSON: string(body), CreatedBy: uint(actorID), CreatedByName: strings.TrimSpace(actorName), } if err := s.jobStore.CreateJob(&job); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return &countryCodeRenameResponse{ JobID: job.ID, Status: job.Status, CountryID: countryID, OldCountryCode: oldCountryCode, NewCountryCode: req.CountryCode, }, nil } func (s *Service) HandleCountryCodeRenameJob(ctx context.Context, job *model.AdminJob) (string, error) { if job == nil { return "", errors.New("job is required") } if s.userDB == nil { return "", errors.New("user db is not configured") } if s.jobStore == nil { return "", errors.New("job store is not configured") } if s.roomClient == nil { return "", errors.New("room service client is not configured") } var payload countryCodeRenameJobPayload if err := json.Unmarshal([]byte(job.PayloadJSON), &payload); err != nil { return "", err } payload = normalizeCountryCodeRenamePayload(payload) if !validCountryCodeRenamePayload(payload) { return "", errCountryInvalid } ctx = appctx.WithContext(ctx, payload.AppCode) userCounts, err := s.renameCountryCodeInUserDB(ctx, payload) if err != nil { return "", err } nowMS := time.Now().UTC().UnixMilli() adminCounts, err := s.jobStore.RenameCountryCodeAdminReferences(payload.AppCode, payload.OldCountryCode, payload.NewCountryCode, nowMS) if err != nil { return "", err } roomCounts, err := s.roomClient.RenameOwnerCountryCode(ctx, roomclient.RenameOwnerCountryCodeRequest{ RequestID: firstNonEmptyString(payload.RequestID, fmt.Sprintf("country-code-rename-job-%d", job.ID)), OldCountryCode: payload.OldCountryCode, NewCountryCode: payload.NewCountryCode, AdminID: uint64(payload.ActorID), }) if err != nil { return "", err } result := countryCodeRenameResult{ CountryID: payload.CountryID, OldCountryCode: payload.OldCountryCode, NewCountryCode: payload.NewCountryCode, Users: userCounts.Users, RegionMappings: userCounts.RegionMappings, RebuildTasks: userCounts.RebuildTasks, Banners: adminCounts.Banners, SplashScreens: adminCounts.SplashScreens, RoomListEntries: roomCounts.RoomListEntries, RoomSnapshots: roomCounts.RoomSnapshots, CompletedAtMs: time.Now().UTC().UnixMilli(), } body, err := json.Marshal(result) if err != nil { return "", err } return string(body), nil } func (s *Service) renameCountryCodeInUserDB(ctx context.Context, payload countryCodeRenameJobPayload) (countryCodeRenameUserCounts, error) { tx, err := s.userDB.BeginTx(ctx, nil) if err != nil { return countryCodeRenameUserCounts{}, err } defer tx.Rollback() country, err := queryCountryForUpdate(ctx, tx, payload.CountryID) if err != nil { return countryCodeRenameUserCounts{}, err } currentCode := strings.ToUpper(strings.TrimSpace(country.CountryCode)) if currentCode != payload.OldCountryCode && currentCode != payload.NewCountryCode { return countryCodeRenameUserCounts{}, errCountryConflict } if err := ensureCountryCodeAvailable(ctx, tx, payload.CountryID, payload.NewCountryCode); err != nil { return countryCodeRenameUserCounts{}, err } nowMS := time.Now().UTC().UnixMilli() if _, err := tx.ExecContext(ctx, ` UPDATE countries SET country_code = ?, country_name = ?, country_display_name = ?, iso_alpha3 = ?, iso_numeric = ?, phone_country_code = ?, flag = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND country_id = ? `, payload.NewCountryCode, payload.CountryName, payload.CountryDisplayName, nullableString(payload.ISOAlpha3), nullableString(payload.ISONumeric), nullableString(payload.PhoneCountryCode), payload.Flag, payload.SortOrder, nullableInt64(payload.ActorID), nowMS, payload.AppCode, payload.CountryID); err != nil { return countryCodeRenameUserCounts{}, mapCountryWriteError(err) } regionResult, err := tx.ExecContext(ctx, ` UPDATE region_countries SET country_code = ?, updated_by_user_id = ?, updated_at_ms = ? WHERE app_code = ? AND country_code = ? `, payload.NewCountryCode, nullableInt64(payload.ActorID), nowMS, payload.AppCode, payload.OldCountryCode) if err != nil { return countryCodeRenameUserCounts{}, err } userResult, err := tx.ExecContext(ctx, ` UPDATE users SET country = ?, updated_at_ms = ? WHERE app_code = ? AND country = ? `, payload.NewCountryCode, nowMS, payload.AppCode, payload.OldCountryCode) if err != nil { return countryCodeRenameUserCounts{}, err } taskResult, err := tx.ExecContext(ctx, ` UPDATE user_region_rebuild_tasks SET country_code = ?, updated_at_ms = ? WHERE app_code = ? AND country_code = ? AND status IN (?, ?) `, payload.NewCountryCode, nowMS, payload.AppCode, payload.OldCountryCode, regionRebuildTaskStatusPending, regionRebuildTaskStatusRunning) if err != nil { return countryCodeRenameUserCounts{}, err } if err := tx.Commit(); err != nil { return countryCodeRenameUserCounts{}, err } regionRows, _ := regionResult.RowsAffected() userRows, _ := userResult.RowsAffected() taskRows, _ := taskResult.RowsAffected() return countryCodeRenameUserCounts{Users: userRows, RegionMappings: regionRows, RebuildTasks: taskRows}, nil } func queryCountryForUpdate(ctx context.Context, tx *sql.Tx, countryID int64) (*userclientCountryForRename, error) { var country userclientCountryForRename if err := tx.QueryRowContext(ctx, ` SELECT country_id, country_code FROM countries WHERE app_code = ? AND country_id = ? FOR UPDATE `, appctx.FromContext(ctx), countryID).Scan(&country.CountryID, &country.CountryCode); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, errCountryNotFound } return nil, err } return &country, nil } type userclientCountryForRename struct { CountryID int64 CountryCode string } func ensureCountryCodeAvailable(ctx context.Context, tx *sql.Tx, countryID int64, countryCode string) error { var existingID int64 err := tx.QueryRowContext(ctx, ` SELECT country_id FROM countries WHERE app_code = ? AND country_code = ? AND country_id <> ? LIMIT 1 FOR UPDATE `, appctx.FromContext(ctx), countryCode, countryID).Scan(&existingID) if errors.Is(err, sql.ErrNoRows) { return nil } if err != nil { return err } return errCountryConflict } func (s *Service) ensureNoActiveCountryCodeRenameJob(ctx context.Context, appCode string, countryID int64, oldCountryCode string, newCountryCode string) error { jobs, err := s.jobStore.ListActiveJobsByType(CountryCodeRenameJobType) if err != nil { return err } for _, job := range jobs { var payload countryCodeRenameJobPayload if err := json.Unmarshal([]byte(job.PayloadJSON), &payload); err != nil { continue } payload = normalizeCountryCodeRenamePayload(payload) if payload.AppCode != appctx.Normalize(appCode) { continue } if payload.CountryID == countryID || payload.OldCountryCode == oldCountryCode || payload.OldCountryCode == newCountryCode || payload.NewCountryCode == oldCountryCode || payload.NewCountryCode == newCountryCode { return errCountryRenameJobConflict } } return nil } func normalizeRenameCountryCodeRequest(req renameCountryCodeRequest) renameCountryCodeRequest { req.CountryName = strings.TrimSpace(req.CountryName) req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode)) req.CountryDisplayName = strings.TrimSpace(req.CountryDisplayName) req.ISOAlpha3 = strings.ToUpper(strings.TrimSpace(req.ISOAlpha3)) req.ISONumeric = strings.TrimSpace(req.ISONumeric) req.PhoneCountryCode = strings.TrimSpace(req.PhoneCountryCode) req.Flag = strings.TrimSpace(req.Flag) return req } func validCountryRenameRequest(req renameCountryCodeRequest) bool { return validCountryCreateRequest(createCountryRequest{ CountryName: req.CountryName, CountryCode: req.CountryCode, CountryDisplayName: req.CountryDisplayName, SortOrder: req.SortOrder, ISOAlpha3: req.ISOAlpha3, ISONumeric: req.ISONumeric, PhoneCountryCode: req.PhoneCountryCode, Flag: req.Flag, }) } func normalizeCountryCodeRenamePayload(payload countryCodeRenameJobPayload) countryCodeRenameJobPayload { payload.AppCode = appctx.Normalize(payload.AppCode) payload.OldCountryCode = strings.ToUpper(strings.TrimSpace(payload.OldCountryCode)) payload.NewCountryCode = strings.ToUpper(strings.TrimSpace(payload.NewCountryCode)) payload.CountryName = strings.TrimSpace(payload.CountryName) payload.CountryDisplayName = strings.TrimSpace(payload.CountryDisplayName) payload.ISOAlpha3 = strings.ToUpper(strings.TrimSpace(payload.ISOAlpha3)) payload.ISONumeric = strings.TrimSpace(payload.ISONumeric) payload.PhoneCountryCode = strings.TrimSpace(payload.PhoneCountryCode) payload.Flag = strings.TrimSpace(payload.Flag) payload.ActorName = strings.TrimSpace(payload.ActorName) payload.RequestID = strings.TrimSpace(payload.RequestID) return payload } func validCountryCodeRenamePayload(payload countryCodeRenameJobPayload) bool { return payload.CountryID > 0 && payload.ActorID >= 0 && payload.OldCountryCode != payload.NewCountryCode && countryCodePattern.MatchString(payload.OldCountryCode) && validCountryRenameRequest(renameCountryCodeRequest{ CountryName: payload.CountryName, CountryCode: payload.NewCountryCode, CountryDisplayName: payload.CountryDisplayName, SortOrder: payload.SortOrder, ISOAlpha3: payload.ISOAlpha3, ISONumeric: payload.ISONumeric, PhoneCountryCode: payload.PhoneCountryCode, Flag: payload.Flag, }) } func firstNonEmptyString(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" }