From 5670b0455d8b4cf4956f5d2843c441e7eaadde82 Mon Sep 17 00:00:00 2001 From: db123 Date: Thu, 25 Jun 2026 12:27:03 +0330 Subject: [PATCH] feat: add delete account button in settings, implement all remaining achievements - Add delete all data button with red danger style and confirmation prompt - Add DeleteUserData repo method that wipes user from all tables in a transaction - Add helper methods deleteAccountPrompt and deleteAccountConfirm - Implement consistency_master, perfect_week, remote_veteran, onsite_master achievements - Add GetDailyXPInRange and SumWorkSecondsByWorkType repo methods - Update Dockerfile to alpine3.23, docker-compose to use env vars for proxy/paths --- Dockerfile | 4 +- docker-compose.yml | 12 +++--- internal/handler/handler.go | 4 ++ internal/handler/rpg.go | 63 +++++++++++++++++++++++++++ internal/handler/settings.go | 24 +++++++++++ internal/repo/interface.go | 3 ++ internal/repo/store.go | 83 ++++++++++++++++++++++++++++++++++++ 7 files changed, 185 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7a63e0a..9e03ae9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.4-alpine AS builder +FROM golang:1.26.4-alpine3.23 AS builder WORKDIR /src @@ -12,7 +12,7 @@ RUN go mod download COPY . . RUN go build -o /usr/local/bin/worktime-bot ./cmd/bot/ -FROM alpine:3.18 +FROM alpine:3.23 RUN apk add --no-cache ca-certificates tzdata diff --git a/docker-compose.yml b/docker-compose.yml index 0f1b259..ccbd993 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,16 +3,16 @@ services: build: context: . args: - HTTP_PROXY: http://192.168.100.2:10808 - HTTPS_PROXY: http://192.168.100.2:10808 + HTTP_PROXY: ${HTTP_PROXY} + HTTPS_PROXY: ${HTTPS_PROXY} env_file: - .env environment: - BOT_TOKEN=${BOT_TOKEN} - - HTTP_PROXY=${HTTP_PROXY-} - - HTTPS_PROXY=${HTTPS_PROXY-} - - DB_PATH=/data/db.sqlite3 - - TZ=Asia/Tehran + - HTTP_PROXY=${HTTP_PROXY} + - HTTPS_PROXY=${HTTPS_PROXY} + - DB_PATH=${DB_PATH} + - TZ=${TZ} volumes: - worktime_data:/data diff --git a/internal/handler/handler.go b/internal/handler/handler.go index cedb89c..aea0394 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -323,6 +323,10 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID h.reportTimeCallback(ctx, chatID, msgID, cbID, user) case data == "breakthreshold": h.breakThresholdCallback(ctx, chatID, msgID, cbID, user) + case data == "deleteaccount": + h.deleteAccountPrompt(ctx, chatID, msgID, cbID, user) + case strings.HasPrefix(data, "delaccount_"): + h.deleteAccountConfirm(ctx, chatID, msgID, cbID, user) case strings.HasPrefix(data, "league_"): sortBy := data[7:] h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy) diff --git a/internal/handler/rpg.go b/internal/handler/rpg.go index ad37c30..822950a 100644 --- a/internal/handler/rpg.go +++ b/internal/handler/rpg.go @@ -241,6 +241,9 @@ func (h *Handler) checkAchievements(userID int64, stats *domain.RPGStats, workSe h.checkHourAchievements(u, stats, totalHours) h.checkSessionAchievements(u, workSeconds, isWeekend) h.checkLevelAchievements(u, stats) + h.checkConsistencyAchievements(u, userID, date) + h.checkPerfectWeekAchievements(u, userID, date) + h.checkWorkTypeAchievements(u, userID) return u.unlocked } @@ -334,3 +337,63 @@ func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *domain.R u.unlock("level_50") } } + +func (h *Handler) checkConsistencyAchievements(u *achievementUnlocker, userID int64, today string) { + t, err := time.Parse(domain.DateLayout, today) + if err != nil { + return + } + sevenDaysAgo := t.AddDate(0, 0, -7).Format(domain.DateLayout) + yesterday := t.AddDate(0, 0, -1).Format(domain.DateLayout) + records, err := h.Repo.GetDailyXPInRange(userID, sevenDaysAgo, yesterday) + if err != nil { + return + } + if len(records) < 7 { + return + } + allHaveSixHours := true + for _, r := range records { + if r.WorkSeconds < 6*domain.SecondsPerHour { + allHaveSixHours = false + break + } + } + if allHaveSixHours { + u.unlock("consistency_master") + } +} + +func (h *Handler) checkPerfectWeekAchievements(u *achievementUnlocker, userID int64, today string) { + t, err := time.Parse(domain.DateLayout, today) + if err != nil { + return + } + start := t.AddDate(0, 0, -6).Format(domain.DateLayout) + days, err := h.Repo.GetUserDaysInRange(userID, start, today) + if err != nil { + return + } + if len(days) < 7 { + return + } + for _, d := range days { + if d.IsDayOff { + return + } + } + u.unlock("perfect_week") +} + +func (h *Handler) checkWorkTypeAchievements(u *achievementUnlocker, userID int64) { + rem, ons, err := h.Repo.SumWorkSecondsByWorkType(userID) + if err != nil { + return + } + if rem >= 100*domain.SecondsPerHour { + u.unlock("remote_veteran") + } + if ons >= 100*domain.SecondsPerHour { + u.unlock("onsite_master") + } +} diff --git a/internal/handler/settings.go b/internal/handler/settings.go index 53377e0..bd5dd67 100644 --- a/internal/handler/settings.go +++ b/internal/handler/settings.go @@ -112,6 +112,7 @@ func (h *Handler) buildSettingsKeyboard(user *domain.User, st *domain.UserSettin {Text: fmt.Sprintf("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"}, {Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"}, }, + {{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}}, } return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows} @@ -730,3 +731,26 @@ func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *do st.ReportTime = timeStr return h.Repo.UpdateSettings(st) } + +func (h *Handler) deleteAccountPrompt(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + kb := models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + { + {Text: "Yes, delete everything", CallbackData: fmt.Sprintf("delaccount_%d", user.ID), Style: "danger"}, + {Text: "Cancel", CallbackData: "back_settings"}, + }, + }, + } + h.editText(ctx, chatID, msgID, "This will permanently delete ALL your data (events, days, RPG progress, settings, achievements).\nAre you sure?", &kb) +} + +func (h *Handler) deleteAccountConfirm(ctx context.Context, chatID int64, msgID int, cbID string, user *domain.User) { + defer h.answerCb(ctx, cbID) + if err := h.Repo.DeleteUserData(user.ID); err != nil { + slog.Error("delete user data", "user_id", user.ID, "error", err) + h.editText(ctx, chatID, msgID, "Error deleting data. Please try again.", nil) + return + } + h.editText(ctx, chatID, msgID, "All your data has been deleted. You can start fresh by sending /start.", nil) +} diff --git a/internal/repo/interface.go b/internal/repo/interface.go index 5679e1e..88f088a 100644 --- a/internal/repo/interface.go +++ b/internal/repo/interface.go @@ -44,6 +44,8 @@ type Repository interface { UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error SumDailyWorkSeconds(userID int64) (int64, error) + GetDailyXPInRange(userID int64, start, end string) ([]domain.DailyXP, error) + SumWorkSecondsByWorkType(userID int64) (remoteSeconds, onsiteSeconds int64, _ error) // Achievements GetAchievementByCode(code string) (*domain.Achievement, error) @@ -55,5 +57,6 @@ type Repository interface { GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error) // Lifecycle + DeleteUserData(userID int64) error Close() error } diff --git a/internal/repo/store.go b/internal/repo/store.go index c8d083b..e86f03e 100644 --- a/internal/repo/store.go +++ b/internal/repo/store.go @@ -35,6 +35,38 @@ func NewStore(path string) (*Store, error) { return &Store{db: db}, nil } +func (s *Store) DeleteUserData(userID int64) error { + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec(`DELETE FROM events WHERE day_id IN (SELECT id FROM days WHERE user_id=?)`, userID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM days WHERE user_id=?`, userID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM user_achievements WHERE user_id=?`, userID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM daily_xp WHERE user_id=?`, userID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM user_rpg WHERE user_id=?`, userID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM user_settings WHERE user_id=?`, userID); err != nil { + return err + } + if _, err := tx.Exec(`DELETE FROM users WHERE id=?`, userID); err != nil { + return err + } + + return tx.Commit() +} + func (s *Store) Close() error { return s.db.Close() } @@ -233,6 +265,57 @@ func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) { return total, err } +func (s *Store) GetDailyXPInRange(userID int64, start, end string) ([]domain.DailyXP, error) { + rows, err := s.db.Query( + `SELECT id, user_id, date, xp_earned, work_seconds, created_at + FROM daily_xp WHERE user_id=? AND date >= ? AND date <= ? + ORDER BY date`, userID, start, end, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var result []domain.DailyXP + for rows.Next() { + var x domain.DailyXP + if err := rows.Scan(&x.ID, &x.UserID, &x.Date, &x.XPEarned, &x.WorkSeconds, &x.CreatedAt); err != nil { + return nil, err + } + result = append(result, x) + } + return result, rows.Err() +} + +func (s *Store) SumWorkSecondsByWorkType(userID int64) (remoteSeconds, onsiteSeconds int64, _ error) { + // Each day has a current_work_type_id. Daily XP work_seconds are per day. + // Join with days to get the day's primary work type, then sum by type. + rows, err := s.db.Query(` + SELECT COALESCE(d.current_work_type_id, 1), COALESCE(SUM(dx.work_seconds), 0) + FROM daily_xp dx + JOIN days d ON d.user_id = dx.user_id AND d.date = dx.date + WHERE dx.user_id = ? AND dx.work_seconds > 0 + GROUP BY d.current_work_type_id + `, userID) + if err != nil { + return 0, 0, err + } + defer rows.Close() + for rows.Next() { + var wtID int64 + var secs int64 + if err := rows.Scan(&wtID, &secs); err != nil { + return 0, 0, err + } + switch wtID { + case 2: + remoteSeconds += secs + default: + onsiteSeconds += secs + } + } + return remoteSeconds, onsiteSeconds, rows.Err() +} + // Achievements func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) {