feat: add delete account button in settings, implement all remaining achievements
All checks were successful
CI / build (push) Successful in 37s

- 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
This commit is contained in:
2026-06-25 12:27:03 +03:30
parent 0926323d18
commit 5670b0455d
7 changed files with 185 additions and 8 deletions

View File

@@ -1,4 +1,4 @@
FROM golang:1.26.4-alpine AS builder FROM golang:1.26.4-alpine3.23 AS builder
WORKDIR /src WORKDIR /src
@@ -12,7 +12,7 @@ RUN go mod download
COPY . . COPY . .
RUN go build -o /usr/local/bin/worktime-bot ./cmd/bot/ 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 RUN apk add --no-cache ca-certificates tzdata

View File

@@ -3,16 +3,16 @@ services:
build: build:
context: . context: .
args: args:
HTTP_PROXY: http://192.168.100.2:10808 HTTP_PROXY: ${HTTP_PROXY}
HTTPS_PROXY: http://192.168.100.2:10808 HTTPS_PROXY: ${HTTPS_PROXY}
env_file: env_file:
- .env - .env
environment: environment:
- BOT_TOKEN=${BOT_TOKEN} - BOT_TOKEN=${BOT_TOKEN}
- HTTP_PROXY=${HTTP_PROXY-} - HTTP_PROXY=${HTTP_PROXY}
- HTTPS_PROXY=${HTTPS_PROXY-} - HTTPS_PROXY=${HTTPS_PROXY}
- DB_PATH=/data/db.sqlite3 - DB_PATH=${DB_PATH}
- TZ=Asia/Tehran - TZ=${TZ}
volumes: volumes:
- worktime_data:/data - worktime_data:/data

View File

@@ -323,6 +323,10 @@ func (h *Handler) routePrefixedCallback(ctx context.Context, chatID int64, msgID
h.reportTimeCallback(ctx, chatID, msgID, cbID, user) h.reportTimeCallback(ctx, chatID, msgID, cbID, user)
case data == "breakthreshold": case data == "breakthreshold":
h.breakThresholdCallback(ctx, chatID, msgID, cbID, user) 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_"): case strings.HasPrefix(data, "league_"):
sortBy := data[7:] sortBy := data[7:]
h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy) h.leagueCallback(ctx, chatID, msgID, cbID, user, sortBy)

View File

@@ -241,6 +241,9 @@ func (h *Handler) checkAchievements(userID int64, stats *domain.RPGStats, workSe
h.checkHourAchievements(u, stats, totalHours) h.checkHourAchievements(u, stats, totalHours)
h.checkSessionAchievements(u, workSeconds, isWeekend) h.checkSessionAchievements(u, workSeconds, isWeekend)
h.checkLevelAchievements(u, stats) h.checkLevelAchievements(u, stats)
h.checkConsistencyAchievements(u, userID, date)
h.checkPerfectWeekAchievements(u, userID, date)
h.checkWorkTypeAchievements(u, userID)
return u.unlocked return u.unlocked
} }
@@ -334,3 +337,63 @@ func (h *Handler) checkLevelAchievements(u *achievementUnlocker, stats *domain.R
u.unlock("level_50") 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")
}
}

View File

@@ -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("Rate: %.2f", st.HourlyRate), CallbackData: "setrate"},
{Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"}, {Text: fmt.Sprintf("Currency: %s", st.Currency), CallbackData: "currency"},
}, },
{{Text: "Delete all data", CallbackData: "deleteaccount", Style: "danger"}},
{{Text: "Back to Menu", CallbackData: "back_menu"}}, {{Text: "Back to Menu", CallbackData: "back_menu"}},
} }
return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows} return "Settings:", models.InlineKeyboardMarkup{InlineKeyboard: rows}
@@ -730,3 +731,26 @@ func (h *Handler) validateAndSaveReportTime(_ context.Context, _ int64, user *do
st.ReportTime = timeStr st.ReportTime = timeStr
return h.Repo.UpdateSettings(st) 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)
}

View File

@@ -44,6 +44,8 @@ type Repository interface {
UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error UpsertDailyXP(userID int64, date string, xpEarned, workSeconds int64) error
SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error SetDailyWorkSeconds(userID int64, date string, workSeconds int64) error
SumDailyWorkSeconds(userID int64) (int64, error) SumDailyWorkSeconds(userID int64) (int64, error)
GetDailyXPInRange(userID int64, start, end string) ([]domain.DailyXP, error)
SumWorkSecondsByWorkType(userID int64) (remoteSeconds, onsiteSeconds int64, _ error)
// Achievements // Achievements
GetAchievementByCode(code string) (*domain.Achievement, error) GetAchievementByCode(code string) (*domain.Achievement, error)
@@ -55,5 +57,6 @@ type Repository interface {
GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error) GetLeagueRankings(orderBy string) ([]domain.LeagueEntry, error)
// Lifecycle // Lifecycle
DeleteUserData(userID int64) error
Close() error Close() error
} }

View File

@@ -35,6 +35,38 @@ func NewStore(path string) (*Store, error) {
return &Store{db: db}, nil 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 { func (s *Store) Close() error {
return s.db.Close() return s.db.Close()
} }
@@ -233,6 +265,57 @@ func (s *Store) SumDailyWorkSeconds(userID int64) (int64, error) {
return total, err 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 // Achievements
func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) { func (s *Store) GetAchievementByCode(code string) (*domain.Achievement, error) {