feat: add delete account button in settings, implement all remaining achievements
All checks were successful
CI / build (push) Successful in 37s
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user