package service import ( "context" "errors" "fmt" "strings" "time" "gorm.io/gorm" smokemodel "wx_service/internal/smoke/model" ) var ( ErrSmokeProfileInvalidTime = errors.New("invalid time format, expected HH:MM") ) type SmokeProfileService struct { db *gorm.DB } func NewSmokeProfileService(db *gorm.DB) *SmokeProfileService { return &SmokeProfileService{db: db} } type SmokeProfileView struct { Exists bool `json:"exists"` Profile *smokemodel.SmokeUserProfile `json:"profile,omitempty"` IsCompleted bool `json:"is_completed"` AwakeMinutes int `json:"awake_minutes"` BaselineIntervalMinute int `json:"baseline_interval_minutes"` } func (s *SmokeProfileService) GetView(ctx context.Context, uid int) (SmokeProfileView, error) { profile, err := s.Get(ctx, uid) if err != nil { return SmokeProfileView{}, err } if profile == nil { return SmokeProfileView{ Exists: false, Profile: nil, IsCompleted: false, AwakeMinutes: defaultAwakeMinutes, BaselineIntervalMinute: 0, }, nil } awakeMinutes, err := awakeMinutesWithFallback(profile.WakeUpTime, profile.SleepTime) if err != nil { return SmokeProfileView{}, err } isCompleted := isSmokeProfileCompleted(*profile) interval := baselineIntervalMinutes(awakeMinutes, profile.BaselineCigsPerDay) return SmokeProfileView{ Exists: true, Profile: profile, IsCompleted: isCompleted, AwakeMinutes: awakeMinutes, BaselineIntervalMinute: interval, }, nil } func (s *SmokeProfileService) Get(ctx context.Context, uid int) (*smokemodel.SmokeUserProfile, error) { var profile smokemodel.SmokeUserProfile err := s.db.WithContext(ctx). Where("uid = ? AND deleted_at IS NULL", uid). First(&profile).Error if err == nil { return &profile, nil } if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, fmt.Errorf("load smoke profile: %w", err) } type UpsertSmokeProfileRequest struct { BaselineCigsPerDay *int SmokingYears *float64 PackPriceCent *int SmokeMotivations *[]string QuitMotivations *[]string WakeUpTime *string SleepTime *string QuitDateProvided bool QuitDate *time.Time } func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmokeProfileRequest) (SmokeProfileView, error) { var profile smokemodel.SmokeUserProfile tx := s.db.WithContext(ctx) err := tx.Where("uid = ? AND deleted_at IS NULL", uid).First(&profile).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return SmokeProfileView{}, fmt.Errorf("load smoke profile: %w", err) } isNew := errors.Is(err, gorm.ErrRecordNotFound) if isNew { profile = smokemodel.SmokeUserProfile{UID: uid} } applyInt := func(dst *int, v *int) { if v != nil { *dst = *v } } applyFloat := func(dst *float64, v *float64) { if v != nil { *dst = *v } } applyTimeStr := func(dst *string, v *string) error { if v == nil { return nil } value := strings.TrimSpace(*v) if value == "" { *dst = "" return nil } if _, err := parseHHMMToMinutes(value); err != nil { return ErrSmokeProfileInvalidTime } *dst = value return nil } applyInt(&profile.BaselineCigsPerDay, req.BaselineCigsPerDay) applyFloat(&profile.SmokingYears, req.SmokingYears) applyInt(&profile.PackPriceCent, req.PackPriceCent) if req.SmokeMotivations != nil { profile.SmokeMotivations = smokemodel.StringSlice(*req.SmokeMotivations) } if req.QuitMotivations != nil { profile.QuitMotivations = smokemodel.StringSlice(*req.QuitMotivations) } if err := applyTimeStr(&profile.WakeUpTime, req.WakeUpTime); err != nil { return SmokeProfileView{}, err } if err := applyTimeStr(&profile.SleepTime, req.SleepTime); err != nil { return SmokeProfileView{}, err } if req.QuitDateProvided { profile.QuitDate = req.QuitDate } now := time.Now() if profile.OnboardingCompletedAt == nil && isSmokeProfileCompleted(profile) { profile.OnboardingCompletedAt = &now } if isNew { if err := tx.Create(&profile).Error; err != nil { return SmokeProfileView{}, fmt.Errorf("create smoke profile: %w", err) } } else { if err := tx.Save(&profile).Error; err != nil { return SmokeProfileView{}, fmt.Errorf("save smoke profile: %w", err) } } return s.GetView(ctx, uid) } func isSmokeProfileCompleted(p smokemodel.SmokeUserProfile) bool { return p.BaselineCigsPerDay > 0 && p.PackPriceCent > 0 && len(p.QuitMotivations) > 0 && strings.TrimSpace(p.WakeUpTime) != "" && strings.TrimSpace(p.SleepTime) != "" } const defaultAwakeMinutes = 16 * 60 func awakeMinutesWithFallback(wakeUp, sleep string) (int, error) { wakeUp = strings.TrimSpace(wakeUp) sleep = strings.TrimSpace(sleep) if wakeUp == "" || sleep == "" { return defaultAwakeMinutes, nil } wakeMin, err := parseHHMMToMinutes(wakeUp) if err != nil { return 0, ErrSmokeProfileInvalidTime } sleepMin, err := parseHHMMToMinutes(sleep) if err != nil { return 0, ErrSmokeProfileInvalidTime } if sleepMin == wakeMin { return 24 * 60, nil } if sleepMin > wakeMin { return sleepMin - wakeMin, nil } return (24*60 - wakeMin) + sleepMin, nil } func baselineIntervalMinutes(awakeMinutes int, baselineCigsPerDay int) int { if awakeMinutes <= 0 || baselineCigsPerDay <= 0 { return 0 } interval := awakeMinutes / baselineCigsPerDay if interval <= 0 { return 1 } return interval } func parseHHMMToMinutes(s string) (int, error) { s = strings.TrimSpace(s) if len(s) != 5 || s[2] != ':' { return 0, ErrSmokeProfileInvalidTime } h1, h2 := s[0], s[1] m1, m2 := s[3], s[4] if h1 < '0' || h1 > '9' || h2 < '0' || h2 > '9' || m1 < '0' || m1 > '9' || m2 < '0' || m2 > '9' { return 0, ErrSmokeProfileInvalidTime } hour := int(h1-'0')*10 + int(h2-'0') min := int(m1-'0')*10 + int(m2-'0') if hour < 0 || hour > 23 || min < 0 || min > 59 { return 0, ErrSmokeProfileInvalidTime } return hour*60 + min, nil }