From 9e467b044832cf53ed381dcc2402a4e6e444679a Mon Sep 17 00:00:00 2001 From: Cosmo Date: Fri, 6 Feb 2026 13:16:50 +0000 Subject: [PATCH] feat: add Telegram bot with notifications and scheduler --- Dockerfile | 6 +- cmd/api/main.go | 23 +++ docker-compose.yml | 1 + go.mod | 2 + internal/bot/bot.go | 74 +++++++++ internal/bot/handlers.go | 260 ++++++++++++++++++++++++++++++++ internal/config/config.go | 30 ++-- internal/handler/profile.go | 72 +++++++++ internal/model/habit.go | 65 ++++---- internal/model/task.go | 57 ++++--- internal/model/user.go | 30 +++- internal/repository/db.go | 13 +- internal/repository/habit.go | 69 ++++++++- internal/repository/task.go | 56 +++++-- internal/repository/user.go | 120 ++++++++++++++- internal/scheduler/scheduler.go | 195 ++++++++++++++++++++++++ internal/service/auth.go | 19 ++- internal/service/habit.go | 14 ++ internal/service/task.go | 11 ++ 19 files changed, 1007 insertions(+), 110 deletions(-) create mode 100644 internal/bot/bot.go create mode 100644 internal/bot/handlers.go create mode 100644 internal/handler/profile.go create mode 100644 internal/scheduler/scheduler.go diff --git a/Dockerfile b/Dockerfile index 5ebc16d..4348855 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,11 +8,13 @@ RUN apk add --no-cache git # Copy go mod files COPY go.mod go.sum ./ -RUN go mod download -# Copy source code +# Copy source code first to allow go mod tidy COPY . . +# Update dependencies +RUN go mod tidy && go mod download + # Build RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api diff --git a/cmd/api/main.go b/cmd/api/main.go index 07f94d5..a4cd57f 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -9,10 +9,12 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "github.com/daniil/homelab-api/internal/bot" "github.com/daniil/homelab-api/internal/config" "github.com/daniil/homelab-api/internal/handler" customMiddleware "github.com/daniil/homelab-api/internal/middleware" "github.com/daniil/homelab-api/internal/repository" + "github.com/daniil/homelab-api/internal/scheduler" "github.com/daniil/homelab-api/internal/service" ) @@ -43,11 +45,28 @@ func main() { habitService := service.NewHabitService(habitRepo) taskService := service.NewTaskService(taskRepo) + // Initialize Telegram bot + telegramBot, err := bot.New(cfg.TelegramBotToken, userRepo, taskRepo, habitRepo) + if err != nil { + log.Printf("Failed to initialize Telegram bot: %v", err) + } + + // Start bot in goroutine + if telegramBot != nil { + go telegramBot.Start() + } + + // Initialize scheduler + sched := scheduler.New(telegramBot, userRepo, taskRepo, habitRepo) + sched.Start() + defer sched.Stop() + // Initialize handlers authHandler := handler.NewAuthHandler(authService) habitHandler := handler.NewHabitHandler(habitService) taskHandler := handler.NewTaskHandler(taskService) healthHandler := handler.NewHealthHandler() + profileHandler := handler.NewProfileHandler(userRepo) // Initialize middleware authMiddleware := customMiddleware.NewAuthMiddleware(cfg.JWTSecret) @@ -89,6 +108,10 @@ func main() { r.Put("/auth/me", authHandler.UpdateProfile) r.Put("/auth/password", authHandler.ChangePassword) + // Profile routes + r.Get("/profile", profileHandler.Get) + r.Put("/profile", profileHandler.Update) + // Habits routes r.Get("/habits", habitHandler.List) r.Post("/habits", habitHandler.Create) diff --git a/docker-compose.yml b/docker-compose.yml index 9db3ada..d79e4ab 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - FROM_EMAIL=${FROM_EMAIL:-noreply@digital-home.site} - FROM_NAME=${FROM_NAME:-Homelab} - APP_URL=${APP_URL:-https://api.digital-home.site} + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} depends_on: db: condition: service_healthy diff --git a/go.mod b/go.mod index f288dae..840230b 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,10 @@ go 1.22 require ( github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/cors v1.2.1 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/jmoiron/sqlx v1.3.5 github.com/lib/pq v1.10.9 + github.com/robfig/cron/v3 v3.0.1 golang.org/x/crypto v0.18.0 ) diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..ba2fa80 --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,74 @@ +package bot + +import ( + "log" + "sync" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/daniil/homelab-api/internal/repository" +) + +type Bot struct { + api *tgbotapi.BotAPI + userRepo *repository.UserRepository + taskRepo *repository.TaskRepository + habitRepo *repository.HabitRepository + mu sync.Mutex +} + +func New(token string, userRepo *repository.UserRepository, taskRepo *repository.TaskRepository, habitRepo *repository.HabitRepository) (*Bot, error) { + if token == "" { + return nil, nil + } + + api, err := tgbotapi.NewBotAPI(token) + if err != nil { + return nil, err + } + + log.Printf("Telegram bot authorized on account %s", api.Self.UserName) + + return &Bot{ + api: api, + userRepo: userRepo, + taskRepo: taskRepo, + habitRepo: habitRepo, + }, nil +} + +func (b *Bot) Start() { + if b == nil || b.api == nil { + return + } + + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := b.api.GetUpdatesChan(u) + + for update := range updates { + if update.Message == nil { + continue + } + + go b.handleMessage(update.Message) + } +} + +func (b *Bot) SendMessage(chatID int64, text string) error { + if b == nil || b.api == nil { + return nil + } + + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "HTML" + _, err := b.api.Send(msg) + return err +} + +func (b *Bot) GetAPI() *tgbotapi.BotAPI { + if b == nil { + return nil + } + return b.api +} diff --git a/internal/bot/handlers.go b/internal/bot/handlers.go new file mode 100644 index 0000000..66c0494 --- /dev/null +++ b/internal/bot/handlers.go @@ -0,0 +1,260 @@ +package bot + +import ( + "fmt" + "strconv" + "strings" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" +) + +func (b *Bot) handleMessage(msg *tgbotapi.Message) { + if !msg.IsCommand() { + return + } + + switch msg.Command() { + case "start": + b.handleStart(msg) + case "tasks": + b.handleTasks(msg) + case "habits": + b.handleHabits(msg) + case "done": + b.handleDone(msg) + case "check": + b.handleCheck(msg) + case "help": + b.handleHelp(msg) + } +} + +func (b *Bot) handleStart(msg *tgbotapi.Message) { + text := fmt.Sprintf(`👋 Привет! Я бот Pulse. + +Твой Chat ID: %d + +Скопируй его и вставь в настройках Pulse для получения уведомлений. + +Доступные команды: +/tasks — задачи на сегодня +/habits — привычки на сегодня +/done <id> — отметить задачу выполненной +/check <id> — отметить привычку выполненной +/help — справка`, msg.Chat.ID) + + b.SendMessage(msg.Chat.ID, text) +} + +func (b *Bot) handleHelp(msg *tgbotapi.Message) { + text := `📚 Справка по командам: + +/start — получить твой Chat ID +/tasks — список задач на сегодня +/habits — список привычек +/done <id> — отметить задачу выполненной +/check <id> — отметить привычку выполненной + +💡 Чтобы получать уведомления, добавь свой Chat ID в настройках Pulse.` + + b.SendMessage(msg.Chat.ID, text) +} + +func (b *Bot) handleTasks(msg *tgbotapi.Message) { + user, err := b.userRepo.GetByTelegramChatID(msg.Chat.ID) + if err != nil { + if err == repository.ErrUserNotFound { + b.SendMessage(msg.Chat.ID, "❌ Аккаунт не найден. Добавь свой Chat ID в настройках Pulse.") + return + } + b.SendMessage(msg.Chat.ID, "❌ Ошибка при получении данных") + return + } + + tasks, err := b.taskRepo.GetTodayTasks(user.ID) + if err != nil { + b.SendMessage(msg.Chat.ID, "❌ Ошибка при получении задач") + return + } + + if len(tasks) == 0 { + b.SendMessage(msg.Chat.ID, "✨ На сегодня задач нет!") + return + } + + text := "📋 Задачи на сегодня:\n\n" + for _, task := range tasks { + priority := "" + switch task.Priority { + case 3: + priority = "🔴 " + case 2: + priority = "🟡 " + case 1: + priority = "🔵 " + } + text += fmt.Sprintf("%s%s %s\n", priority, task.Icon, task.Title) + if task.Description != "" { + text += fmt.Sprintf(" %s\n", task.Description) + } + text += fmt.Sprintf(" /done_%d\n\n", task.ID) + } + + b.SendMessage(msg.Chat.ID, text) +} + +func (b *Bot) handleHabits(msg *tgbotapi.Message) { + user, err := b.userRepo.GetByTelegramChatID(msg.Chat.ID) + if err != nil { + if err == repository.ErrUserNotFound { + b.SendMessage(msg.Chat.ID, "❌ Аккаунт не найден. Добавь свой Chat ID в настройках Pulse.") + return + } + b.SendMessage(msg.Chat.ID, "❌ Ошибка при получении данных") + return + } + + habits, err := b.habitRepo.ListByUser(user.ID, false) + if err != nil { + b.SendMessage(msg.Chat.ID, "❌ Ошибка при получении привычек") + return + } + + if len(habits) == 0 { + b.SendMessage(msg.Chat.ID, "✨ У тебя пока нет привычек!") + return + } + + // Filter habits for today + today := int(time.Now().Weekday()) + var todayHabits []model.Habit + for _, habit := range habits { + if habit.Frequency == "daily" { + todayHabits = append(todayHabits, habit) + } else { + for _, day := range habit.TargetDays { + if day == today { + todayHabits = append(todayHabits, habit) + break + } + } + } + } + + if len(todayHabits) == 0 { + b.SendMessage(msg.Chat.ID, "✨ На сегодня привычек нет!") + return + } + + text := "🎯 Привычки на сегодня:\n\n" + for _, habit := range todayHabits { + completed, _ := b.habitRepo.IsHabitCompletedToday(habit.ID, user.ID) + status := "⬜" + if completed { + status = "✅" + } + text += fmt.Sprintf("%s %s %s\n", status, habit.Icon, habit.Name) + if habit.Description != "" { + text += fmt.Sprintf(" %s\n", habit.Description) + } + if !completed { + text += fmt.Sprintf(" /check_%d\n", habit.ID) + } + text += "\n" + } + + b.SendMessage(msg.Chat.ID, text) +} + +func (b *Bot) handleDone(msg *tgbotapi.Message) { + user, err := b.userRepo.GetByTelegramChatID(msg.Chat.ID) + if err != nil { + b.SendMessage(msg.Chat.ID, "❌ Аккаунт не найден") + return + } + + // Parse task ID from command argument or from command like /done_123 + var taskID int64 + args := msg.CommandArguments() + if args != "" { + taskID, _ = strconv.ParseInt(args, 10, 64) + } else { + // Try to parse from command like /done_123 + cmd := msg.Command() + if strings.HasPrefix(cmd, "done_") { + taskID, _ = strconv.ParseInt(strings.TrimPrefix(cmd, "done_"), 10, 64) + } + } + + if taskID == 0 { + b.SendMessage(msg.Chat.ID, "❌ Укажи ID задачи: /done <id>") + return + } + + err = b.taskRepo.Complete(taskID, user.ID) + if err != nil { + if err == repository.ErrTaskNotFound { + b.SendMessage(msg.Chat.ID, "❌ Задача не найдена") + return + } + b.SendMessage(msg.Chat.ID, "❌ Ошибка при выполнении задачи") + return + } + + b.SendMessage(msg.Chat.ID, "✅ Задача выполнена!") +} + +func (b *Bot) handleCheck(msg *tgbotapi.Message) { + user, err := b.userRepo.GetByTelegramChatID(msg.Chat.ID) + if err != nil { + b.SendMessage(msg.Chat.ID, "❌ Аккаунт не найден") + return + } + + // Parse habit ID from command argument or from command like /check_123 + var habitID int64 + args := msg.CommandArguments() + if args != "" { + habitID, _ = strconv.ParseInt(args, 10, 64) + } else { + // Try to parse from command like /check_123 + cmd := msg.Command() + if strings.HasPrefix(cmd, "check_") { + habitID, _ = strconv.ParseInt(strings.TrimPrefix(cmd, "check_"), 10, 64) + } + } + + if habitID == 0 { + b.SendMessage(msg.Chat.ID, "❌ Укажи ID привычки: /check <id>") + return + } + + // Verify habit exists and belongs to user + _, err = b.habitRepo.GetByID(habitID, user.ID) + if err != nil { + if err == repository.ErrHabitNotFound { + b.SendMessage(msg.Chat.ID, "❌ Привычка не найдена") + return + } + b.SendMessage(msg.Chat.ID, "❌ Ошибка при получении привычки") + return + } + + // Create log for today + log := &model.HabitLog{ + HabitID: habitID, + UserID: user.ID, + Date: time.Now(), + Count: 1, + } + err = b.habitRepo.CreateLog(log) + if err != nil { + b.SendMessage(msg.Chat.ID, "❌ Ошибка при отметке привычки") + return + } + + b.SendMessage(msg.Chat.ID, "✅ Привычка отмечена!") +} diff --git a/internal/config/config.go b/internal/config/config.go index e1748a2..49b8311 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,24 +5,26 @@ import ( ) type Config struct { - DatabaseURL string - JWTSecret string - Port string - ResendAPIKey string - FromEmail string - FromName string - AppURL string + DatabaseURL string + JWTSecret string + Port string + ResendAPIKey string + FromEmail string + FromName string + AppURL string + TelegramBotToken string } func Load() *Config { return &Config{ - DatabaseURL: getEnv("DATABASE_URL", "postgres://homelab:homelab@db:5432/homelab?sslmode=disable"), - JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), - Port: getEnv("PORT", "8080"), - ResendAPIKey: getEnv("RESEND_API_KEY", ""), - FromEmail: getEnv("FROM_EMAIL", "noreply@digital-home.site"), - FromName: getEnv("FROM_NAME", "Homelab"), - AppURL: getEnv("APP_URL", "https://api.digital-home.site"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://homelab:homelab@db:5432/homelab?sslmode=disable"), + JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), + Port: getEnv("PORT", "8080"), + ResendAPIKey: getEnv("RESEND_API_KEY", ""), + FromEmail: getEnv("FROM_EMAIL", "noreply@digital-home.site"), + FromName: getEnv("FROM_NAME", "Homelab"), + AppURL: getEnv("APP_URL", "https://api.digital-home.site"), + TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""), } } diff --git a/internal/handler/profile.go b/internal/handler/profile.go new file mode 100644 index 0000000..f27074f --- /dev/null +++ b/internal/handler/profile.go @@ -0,0 +1,72 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/daniil/homelab-api/internal/middleware" + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" +) + +type ProfileHandler struct { + userRepo *repository.UserRepository +} + +func NewProfileHandler(userRepo *repository.UserRepository) *ProfileHandler { + return &ProfileHandler{userRepo: userRepo} +} + +func (h *ProfileHandler) Get(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + user, err := h.userRepo.GetByID(userID) + if err != nil { + writeError(w, "user not found", http.StatusNotFound) + return + } + + user.ProcessForJSON() + + // Return only profile fields + profile := map[string]interface{}{ + "telegram_chat_id": user.TelegramChatIDValue, + "notifications_enabled": user.NotificationsEnabled, + "timezone": user.Timezone, + } + + writeJSON(w, profile, http.StatusOK) +} + +func (h *ProfileHandler) Update(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r.Context()) + + var req model.UpdateProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, "invalid request body", http.StatusBadRequest) + return + } + + err := h.userRepo.UpdateProfile(userID, &req) + if err != nil { + writeError(w, "failed to update profile", http.StatusInternalServerError) + return + } + + // Get updated user + user, err := h.userRepo.GetByID(userID) + if err != nil { + writeError(w, "user not found", http.StatusNotFound) + return + } + + user.ProcessForJSON() + + profile := map[string]interface{}{ + "telegram_chat_id": user.TelegramChatIDValue, + "notifications_enabled": user.NotificationsEnabled, + "timezone": user.Timezone, + } + + writeJSON(w, profile, http.StatusOK) +} diff --git a/internal/model/habit.go b/internal/model/habit.go index 4fe6b98..b1b3b95 100644 --- a/internal/model/habit.go +++ b/internal/model/habit.go @@ -1,22 +1,31 @@ package model import ( + "database/sql" "time" ) type Habit struct { - ID int64 `db:"id" json:"id"` - UserID int64 `db:"user_id" json:"user_id"` - Name string `db:"name" json:"name"` - Description string `db:"description" json:"description"` - Color string `db:"color" json:"color"` - Icon string `db:"icon" json:"icon"` - Frequency string `db:"frequency" json:"frequency"` // daily, weekly, custom - TargetDays []int `db:"-" json:"target_days"` // 0=Sun, 1=Mon, etc. - TargetCount int `db:"target_count" json:"target_count"` - IsArchived bool `db:"is_archived" json:"is_archived"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID int64 `db:"id" json:"id"` + UserID int64 `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description string `db:"description" json:"description"` + Color string `db:"color" json:"color"` + Icon string `db:"icon" json:"icon"` + Frequency string `db:"frequency" json:"frequency"` // daily, weekly, custom + TargetDays []int `db:"-" json:"target_days"` // 0=Sun, 1=Mon, etc. + TargetCount int `db:"target_count" json:"target_count"` + ReminderTime sql.NullString `db:"reminder_time" json:"-"` + ReminderTimeStr *string `db:"-" json:"reminder_time"` + IsArchived bool `db:"is_archived" json:"is_archived"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (h *Habit) ProcessForJSON() { + if h.ReminderTime.Valid { + h.ReminderTimeStr = &h.ReminderTime.String + } } type HabitLog struct { @@ -30,24 +39,26 @@ type HabitLog struct { } type CreateHabitRequest struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Color string `json:"color,omitempty"` - Icon string `json:"icon,omitempty"` - Frequency string `json:"frequency,omitempty"` - TargetDays []int `json:"target_days,omitempty"` - TargetCount int `json:"target_count,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Color string `json:"color,omitempty"` + Icon string `json:"icon,omitempty"` + Frequency string `json:"frequency,omitempty"` + TargetDays []int `json:"target_days,omitempty"` + TargetCount int `json:"target_count,omitempty"` + ReminderTime *string `json:"reminder_time,omitempty"` } type UpdateHabitRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Color *string `json:"color,omitempty"` - Icon *string `json:"icon,omitempty"` - Frequency *string `json:"frequency,omitempty"` - TargetDays []int `json:"target_days,omitempty"` - TargetCount *int `json:"target_count,omitempty"` - IsArchived *bool `json:"is_archived,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Color *string `json:"color,omitempty"` + Icon *string `json:"icon,omitempty"` + Frequency *string `json:"frequency,omitempty"` + TargetDays []int `json:"target_days,omitempty"` + TargetCount *int `json:"target_count,omitempty"` + ReminderTime *string `json:"reminder_time,omitempty"` + IsArchived *bool `json:"is_archived,omitempty"` } type LogHabitRequest struct { diff --git a/internal/model/task.go b/internal/model/task.go index 07b2a6d..d9851fe 100644 --- a/internal/model/task.go +++ b/internal/model/task.go @@ -6,19 +6,21 @@ import ( ) type Task struct { - ID int64 `db:"id" json:"id"` - UserID int64 `db:"user_id" json:"user_id"` - Title string `db:"title" json:"title"` - Description string `db:"description" json:"description"` - Icon string `db:"icon" json:"icon"` - Color string `db:"color" json:"color"` - DueDate sql.NullTime `db:"due_date" json:"-"` - DueDateStr *string `db:"-" json:"due_date"` - Priority int `db:"priority" json:"priority"` // 0=none, 1=low, 2=medium, 3=high - CompletedAt sql.NullTime `db:"completed_at" json:"-"` - Completed bool `db:"-" json:"completed"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID int64 `db:"id" json:"id"` + UserID int64 `db:"user_id" json:"user_id"` + Title string `db:"title" json:"title"` + Description string `db:"description" json:"description"` + Icon string `db:"icon" json:"icon"` + Color string `db:"color" json:"color"` + DueDate sql.NullTime `db:"due_date" json:"-"` + DueDateStr *string `db:"-" json:"due_date"` + Priority int `db:"priority" json:"priority"` // 0=none, 1=low, 2=medium, 3=high + ReminderTime sql.NullString `db:"reminder_time" json:"-"` + ReminderTimeStr *string `db:"-" json:"reminder_time"` + CompletedAt sql.NullTime `db:"completed_at" json:"-"` + Completed bool `db:"-" json:"completed"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } func (t *Task) ProcessForJSON() { @@ -26,23 +28,28 @@ func (t *Task) ProcessForJSON() { s := t.DueDate.Time.Format("2006-01-02") t.DueDateStr = &s } + if t.ReminderTime.Valid { + t.ReminderTimeStr = &t.ReminderTime.String + } t.Completed = t.CompletedAt.Valid } type CreateTaskRequest struct { - Title string `json:"title"` - Description string `json:"description,omitempty"` - Icon string `json:"icon,omitempty"` - Color string `json:"color,omitempty"` - DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD - Priority int `json:"priority,omitempty"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + Color string `json:"color,omitempty"` + DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD + Priority int `json:"priority,omitempty"` + ReminderTime *string `json:"reminder_time,omitempty"` // HH:MM } type UpdateTaskRequest struct { - Title *string `json:"title,omitempty"` - Description *string `json:"description,omitempty"` - Icon *string `json:"icon,omitempty"` - Color *string `json:"color,omitempty"` - DueDate *string `json:"due_date,omitempty"` - Priority *int `json:"priority,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` + Color *string `json:"color,omitempty"` + DueDate *string `json:"due_date,omitempty"` + Priority *int `json:"priority,omitempty"` + ReminderTime *string `json:"reminder_time,omitempty"` } diff --git a/internal/model/user.go b/internal/model/user.go index 1e5bb35..4227a95 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -1,17 +1,28 @@ package model import ( + "database/sql" "time" ) type User struct { - ID int64 `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - PasswordHash string `db:"password_hash" json:"-"` - EmailVerified bool `db:"email_verified" json:"email_verified"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID int64 `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + PasswordHash string `db:"password_hash" json:"-"` + EmailVerified bool `db:"email_verified" json:"email_verified"` + TelegramChatID sql.NullInt64 `db:"telegram_chat_id" json:"-"` + TelegramChatIDValue *int64 `db:"-" json:"telegram_chat_id"` + NotificationsEnabled bool `db:"notifications_enabled" json:"notifications_enabled"` + Timezone string `db:"timezone" json:"timezone"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +func (u *User) ProcessForJSON() { + if u.TelegramChatID.Valid { + u.TelegramChatIDValue = &u.TelegramChatID.Int64 + } } type RegisterRequest struct { @@ -26,7 +37,10 @@ type LoginRequest struct { } type UpdateProfileRequest struct { - Username string `json:"username,omitempty"` + Username *string `json:"username,omitempty"` + TelegramChatID *int64 `json:"telegram_chat_id,omitempty"` + NotificationsEnabled *bool `json:"notifications_enabled,omitempty"` + Timezone *string `json:"timezone,omitempty"` } type ChangePasswordRequest struct { diff --git a/internal/repository/db.go b/internal/repository/db.go index d1292d2..e6ac18a 100644 --- a/internal/repository/db.go +++ b/internal/repository/db.go @@ -32,7 +32,7 @@ func RunMigrations(db *sqlx.DB) error { id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, - description TEXT DEFAULT '''', + description TEXT DEFAULT '', color VARCHAR(20) DEFAULT '#6366f1', icon VARCHAR(50) DEFAULT 'check', frequency VARCHAR(20) DEFAULT 'daily', @@ -48,7 +48,7 @@ func RunMigrations(db *sqlx.DB) error { user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, date DATE NOT NULL, count INTEGER DEFAULT 1, - note TEXT DEFAULT '''', + note TEXT DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(habit_id, date) )`, @@ -65,7 +65,7 @@ func RunMigrations(db *sqlx.DB) error { id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, - description TEXT DEFAULT '''', + description TEXT DEFAULT '', icon VARCHAR(10) DEFAULT '📋', color VARCHAR(7) DEFAULT '#6B7280', due_date DATE, @@ -83,13 +83,18 @@ func RunMigrations(db *sqlx.DB) error { `CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id)`, `CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)`, `CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(user_id, completed_at)`, - // Migration: add email_verified column if not exists `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='email_verified') THEN ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE; END IF; END $$;`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_chat_id BIGINT`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS notifications_enabled BOOLEAN DEFAULT true`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS timezone VARCHAR(50) DEFAULT 'Europe/Moscow'`, + `ALTER TABLE tasks ADD COLUMN IF NOT EXISTS reminder_time TIME`, + `ALTER TABLE habits ADD COLUMN IF NOT EXISTS reminder_time TIME`, + `CREATE INDEX IF NOT EXISTS idx_users_telegram_chat_id ON users(telegram_chat_id)`, } for _, migration := range migrations { diff --git a/internal/repository/habit.go b/internal/repository/habit.go index 6605f7f..a995989 100644 --- a/internal/repository/habit.go +++ b/internal/repository/habit.go @@ -23,8 +23,8 @@ func NewHabitRepository(db *sqlx.DB) *HabitRepository { func (r *HabitRepository) Create(habit *model.Habit) error { query := ` - INSERT INTO habits (user_id, name, description, color, icon, frequency, target_days, target_count) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO habits (user_id, name, description, color, icon, frequency, target_days, target_count, reminder_time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, created_at, updated_at` targetDays := pq.Array(habit.TargetDays) @@ -41,6 +41,7 @@ func (r *HabitRepository) Create(habit *model.Habit) error { habit.Frequency, targetDays, habit.TargetCount, + habit.ReminderTime, ).Scan(&habit.ID, &habit.CreatedAt, &habit.UpdatedAt) } @@ -49,13 +50,13 @@ func (r *HabitRepository) GetByID(id, userID int64) (*model.Habit, error) { var targetDays pq.Int64Array query := ` - SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, is_archived, created_at, updated_at + SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, reminder_time, is_archived, created_at, updated_at FROM habits WHERE id = $1 AND user_id = $2` err := r.db.QueryRow(query, id, userID).Scan( &habit.ID, &habit.UserID, &habit.Name, &habit.Description, &habit.Color, &habit.Icon, &habit.Frequency, &targetDays, - &habit.TargetCount, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt, + &habit.TargetCount, &habit.ReminderTime, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt, ) if err != nil { @@ -69,13 +70,14 @@ func (r *HabitRepository) GetByID(id, userID int64) (*model.Habit, error) { for i, v := range targetDays { habit.TargetDays[i] = int(v) } + habit.ProcessForJSON() return &habit, nil } func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]model.Habit, error) { query := ` - SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, is_archived, created_at, updated_at + SELECT id, user_id, name, description, color, icon, frequency, target_days, target_count, reminder_time, is_archived, created_at, updated_at FROM habits WHERE user_id = $1` if !includeArchived { @@ -97,7 +99,7 @@ func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]mode if err := rows.Scan( &habit.ID, &habit.UserID, &habit.Name, &habit.Description, &habit.Color, &habit.Icon, &habit.Frequency, &targetDays, - &habit.TargetCount, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt, + &habit.TargetCount, &habit.ReminderTime, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt, ); err != nil { return nil, err } @@ -106,6 +108,49 @@ func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]mode for i, v := range targetDays { habit.TargetDays[i] = int(v) } + habit.ProcessForJSON() + + habits = append(habits, habit) + } + + return habits, nil +} + +func (r *HabitRepository) GetHabitsWithReminder(reminderTime string, weekday int) ([]model.Habit, error) { + query := ` + SELECT h.id, h.user_id, h.name, h.description, h.color, h.icon, h.frequency, h.target_days, h.target_count, h.reminder_time, h.is_archived, h.created_at, h.updated_at + FROM habits h + JOIN users u ON h.user_id = u.id + WHERE h.reminder_time = $1 + AND h.is_archived = false + AND (h.frequency = 'daily' OR $2 = ANY(h.target_days)) + AND u.telegram_chat_id IS NOT NULL + AND u.notifications_enabled = true` + + rows, err := r.db.Query(query, reminderTime, weekday) + if err != nil { + return nil, err + } + defer rows.Close() + + var habits []model.Habit + for rows.Next() { + var habit model.Habit + var targetDays pq.Int64Array + + if err := rows.Scan( + &habit.ID, &habit.UserID, &habit.Name, &habit.Description, + &habit.Color, &habit.Icon, &habit.Frequency, &targetDays, + &habit.TargetCount, &habit.ReminderTime, &habit.IsArchived, &habit.CreatedAt, &habit.UpdatedAt, + ); err != nil { + return nil, err + } + + habit.TargetDays = make([]int, len(targetDays)) + for i, v := range targetDays { + habit.TargetDays[i] = int(v) + } + habit.ProcessForJSON() habits = append(habits, habit) } @@ -117,8 +162,8 @@ func (r *HabitRepository) Update(habit *model.Habit) error { query := ` UPDATE habits SET name = $2, description = $3, color = $4, icon = $5, frequency = $6, - target_days = $7, target_count = $8, is_archived = $9, updated_at = CURRENT_TIMESTAMP - WHERE id = $1 AND user_id = $10 + target_days = $7, target_count = $8, reminder_time = $9, is_archived = $10, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $11 RETURNING updated_at` return r.db.QueryRow(query, @@ -130,6 +175,7 @@ func (r *HabitRepository) Update(habit *model.Habit) error { habit.Frequency, pq.Array(habit.TargetDays), habit.TargetCount, + habit.ReminderTime, habit.IsArchived, habit.UserID, ).Scan(&habit.UpdatedAt) @@ -208,6 +254,13 @@ func (r *HabitRepository) GetUserLogsForDate(userID int64, date time.Time) ([]mo return logs, nil } +func (r *HabitRepository) IsHabitCompletedToday(habitID, userID int64) (bool, error) { + today := time.Now().Format("2006-01-02") + var count int + err := r.db.Get(&count, `SELECT COUNT(*) FROM habit_logs WHERE habit_id = $1 AND user_id = $2 AND date = $3`, habitID, userID, today) + return count > 0, err +} + func (r *HabitRepository) GetStats(habitID, userID int64) (*model.HabitStats, error) { stats := &model.HabitStats{HabitID: habitID} diff --git a/internal/repository/task.go b/internal/repository/task.go index 88d3305..ae33581 100644 --- a/internal/repository/task.go +++ b/internal/repository/task.go @@ -21,8 +21,8 @@ func NewTaskRepository(db *sqlx.DB) *TaskRepository { func (r *TaskRepository) Create(task *model.Task) error { query := ` - INSERT INTO tasks (user_id, title, description, icon, color, due_date, priority) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO tasks (user_id, title, description, icon, color, due_date, priority, reminder_time) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, created_at, updated_at` return r.db.QueryRow(query, @@ -33,6 +33,7 @@ func (r *TaskRepository) Create(task *model.Task) error { task.Color, task.DueDate, task.Priority, + task.ReminderTime, ).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt) } @@ -40,13 +41,13 @@ func (r *TaskRepository) GetByID(id, userID int64) (*model.Task, error) { var task model.Task query := ` - SELECT id, user_id, title, description, icon, color, due_date, priority, completed_at, created_at, updated_at + SELECT id, user_id, title, description, icon, color, due_date, priority, reminder_time, completed_at, created_at, updated_at FROM tasks WHERE id = $1 AND user_id = $2` err := r.db.QueryRow(query, id, userID).Scan( &task.ID, &task.UserID, &task.Title, &task.Description, &task.Icon, &task.Color, &task.DueDate, &task.Priority, - &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, + &task.ReminderTime, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, ) if err != nil { @@ -62,7 +63,7 @@ func (r *TaskRepository) GetByID(id, userID int64) (*model.Task, error) { func (r *TaskRepository) ListByUser(userID int64, completed *bool) ([]model.Task, error) { query := ` - SELECT id, user_id, title, description, icon, color, due_date, priority, completed_at, created_at, updated_at + SELECT id, user_id, title, description, icon, color, due_date, priority, reminder_time, completed_at, created_at, updated_at FROM tasks WHERE user_id = $1` if completed != nil { @@ -87,7 +88,7 @@ func (r *TaskRepository) ListByUser(userID int64, completed *bool) ([]model.Task if err := rows.Scan( &task.ID, &task.UserID, &task.Title, &task.Description, &task.Icon, &task.Color, &task.DueDate, &task.Priority, - &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, + &task.ReminderTime, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, ); err != nil { return nil, err } @@ -103,7 +104,7 @@ func (r *TaskRepository) GetTodayTasks(userID int64) ([]model.Task, error) { today := time.Now().Format("2006-01-02") query := ` - SELECT id, user_id, title, description, icon, color, due_date, priority, completed_at, created_at, updated_at + SELECT id, user_id, title, description, icon, color, due_date, priority, reminder_time, completed_at, created_at, updated_at FROM tasks WHERE user_id = $1 AND completed_at IS NULL AND due_date <= $2 ORDER BY priority DESC, due_date, created_at` @@ -121,7 +122,7 @@ func (r *TaskRepository) GetTodayTasks(userID int64) ([]model.Task, error) { if err := rows.Scan( &task.ID, &task.UserID, &task.Title, &task.Description, &task.Icon, &task.Color, &task.DueDate, &task.Priority, - &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, + &task.ReminderTime, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, ); err != nil { return nil, err } @@ -133,11 +134,45 @@ func (r *TaskRepository) GetTodayTasks(userID int64) ([]model.Task, error) { return tasks, nil } +func (r *TaskRepository) GetTasksWithReminder(reminderTime string, date string) ([]model.Task, error) { + query := ` + SELECT t.id, t.user_id, t.title, t.description, t.icon, t.color, t.due_date, t.priority, t.reminder_time, t.completed_at, t.created_at, t.updated_at + FROM tasks t + JOIN users u ON t.user_id = u.id + WHERE t.reminder_time = $1 + AND t.completed_at IS NULL + AND (t.due_date IS NULL OR t.due_date >= $2) + AND u.telegram_chat_id IS NOT NULL + AND u.notifications_enabled = true` + + rows, err := r.db.Query(query, reminderTime, date) + if err != nil { + return nil, err + } + defer rows.Close() + + var tasks []model.Task + for rows.Next() { + var task model.Task + if err := rows.Scan( + &task.ID, &task.UserID, &task.Title, &task.Description, + &task.Icon, &task.Color, &task.DueDate, &task.Priority, + &task.ReminderTime, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, + ); err != nil { + return nil, err + } + task.ProcessForJSON() + tasks = append(tasks, task) + } + + return tasks, nil +} + func (r *TaskRepository) Update(task *model.Task) error { query := ` UPDATE tasks - SET title = $2, description = $3, icon = $4, color = $5, due_date = $6, priority = $7, updated_at = CURRENT_TIMESTAMP - WHERE id = $1 AND user_id = $8 + SET title = $2, description = $3, icon = $4, color = $5, due_date = $6, priority = $7, reminder_time = $8, updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND user_id = $9 RETURNING updated_at` return r.db.QueryRow(query, @@ -148,6 +183,7 @@ func (r *TaskRepository) Update(task *model.Task) error { task.Color, task.DueDate, task.Priority, + task.ReminderTime, task.UserID, ).Scan(&task.UpdatedAt) } diff --git a/internal/repository/user.go b/internal/repository/user.go index 64fd7a6..38cfec6 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -3,6 +3,7 @@ package repository import ( "database/sql" "errors" + "fmt" "github.com/daniil/homelab-api/internal/model" "github.com/jmoiron/sqlx" @@ -40,9 +41,20 @@ func (r *UserRepository) Create(user *model.User) error { func (r *UserRepository) GetByID(id int64) (*model.User, error) { var user model.User - query := `SELECT id, email, username, password_hash, email_verified, created_at, updated_at FROM users WHERE id = $1` + query := `SELECT id, email, username, password_hash, email_verified, + telegram_chat_id, + COALESCE(notifications_enabled, true) as notifications_enabled, + COALESCE(timezone, $2) as timezone, + created_at, updated_at + FROM users WHERE id = $1` - if err := r.db.Get(&user, query, id); err != nil { + row := r.db.QueryRow(query, id, "Europe/Moscow") + err := row.Scan( + &user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.EmailVerified, + &user.TelegramChatID, &user.NotificationsEnabled, &user.Timezone, + &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } @@ -54,9 +66,20 @@ func (r *UserRepository) GetByID(id int64) (*model.User, error) { func (r *UserRepository) GetByEmail(email string) (*model.User, error) { var user model.User - query := `SELECT id, email, username, password_hash, email_verified, created_at, updated_at FROM users WHERE email = $1` + query := `SELECT id, email, username, password_hash, email_verified, + telegram_chat_id, + COALESCE(notifications_enabled, true) as notifications_enabled, + COALESCE(timezone, $2) as timezone, + created_at, updated_at + FROM users WHERE email = $1` - if err := r.db.Get(&user, query, email); err != nil { + row := r.db.QueryRow(query, email, "Europe/Moscow") + err := row.Scan( + &user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.EmailVerified, + &user.TelegramChatID, &user.NotificationsEnabled, &user.Timezone, + &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } @@ -66,6 +89,64 @@ func (r *UserRepository) GetByEmail(email string) (*model.User, error) { return &user, nil } +func (r *UserRepository) GetByTelegramChatID(chatID int64) (*model.User, error) { + var user model.User + query := `SELECT id, email, username, password_hash, email_verified, + telegram_chat_id, + COALESCE(notifications_enabled, true) as notifications_enabled, + COALESCE(timezone, $2) as timezone, + created_at, updated_at + FROM users WHERE telegram_chat_id = $1` + + row := r.db.QueryRow(query, chatID, "Europe/Moscow") + err := row.Scan( + &user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.EmailVerified, + &user.TelegramChatID, &user.NotificationsEnabled, &user.Timezone, + &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, err + } + + return &user, nil +} + +func (r *UserRepository) GetUsersWithNotifications() ([]model.User, error) { + query := `SELECT id, email, username, password_hash, email_verified, + telegram_chat_id, + COALESCE(notifications_enabled, true) as notifications_enabled, + COALESCE(timezone, $1) as timezone, + created_at, updated_at + FROM users + WHERE telegram_chat_id IS NOT NULL + AND notifications_enabled = true` + + rows, err := r.db.Query(query, "Europe/Moscow") + if err != nil { + return nil, err + } + defer rows.Close() + + var users []model.User + for rows.Next() { + var user model.User + err := rows.Scan( + &user.ID, &user.Email, &user.Username, &user.PasswordHash, &user.EmailVerified, + &user.TelegramChatID, &user.NotificationsEnabled, &user.Timezone, + &user.CreatedAt, &user.UpdatedAt, + ) + if err != nil { + return nil, err + } + users = append(users, user) + } + + return users, nil +} + func (r *UserRepository) Update(user *model.User) error { query := ` UPDATE users @@ -76,6 +157,37 @@ func (r *UserRepository) Update(user *model.User) error { return r.db.QueryRow(query, user.ID, user.Username).Scan(&user.UpdatedAt) } +func (r *UserRepository) UpdateProfile(id int64, req *model.UpdateProfileRequest) error { + query := `UPDATE users SET updated_at = CURRENT_TIMESTAMP` + args := []interface{}{id} + argIdx := 2 + + if req.Username != nil { + query += fmt.Sprintf(", username = $%d", argIdx) + args = append(args, *req.Username) + argIdx++ + } + if req.TelegramChatID != nil { + query += fmt.Sprintf(", telegram_chat_id = $%d", argIdx) + args = append(args, *req.TelegramChatID) + argIdx++ + } + if req.NotificationsEnabled != nil { + query += fmt.Sprintf(", notifications_enabled = $%d", argIdx) + args = append(args, *req.NotificationsEnabled) + argIdx++ + } + if req.Timezone != nil { + query += fmt.Sprintf(", timezone = $%d", argIdx) + args = append(args, *req.Timezone) + argIdx++ + } + + query += " WHERE id = $1" + _, err := r.db.Exec(query, args...) + return err +} + func (r *UserRepository) UpdatePassword(id int64, passwordHash string) error { query := `UPDATE users SET password_hash = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1` _, err := r.db.Exec(query, id, passwordHash) diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..1f3ac0d --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,195 @@ +package scheduler + +import ( + "fmt" + "log" + "time" + + "github.com/robfig/cron/v3" + "github.com/daniil/homelab-api/internal/bot" + "github.com/daniil/homelab-api/internal/model" + "github.com/daniil/homelab-api/internal/repository" +) + +type Scheduler struct { + cron *cron.Cron + bot *bot.Bot + userRepo *repository.UserRepository + taskRepo *repository.TaskRepository + habitRepo *repository.HabitRepository +} + +func New(b *bot.Bot, userRepo *repository.UserRepository, taskRepo *repository.TaskRepository, habitRepo *repository.HabitRepository) *Scheduler { + return &Scheduler{ + cron: cron.New(), + bot: b, + userRepo: userRepo, + taskRepo: taskRepo, + habitRepo: habitRepo, + } +} + +func (s *Scheduler) Start() { + if s.bot == nil || s.bot.GetAPI() == nil { + log.Println("Scheduler: bot not configured, skipping") + return + } + + // Run every minute + s.cron.AddFunc("* * * * *", s.checkNotifications) + + s.cron.Start() + log.Println("Scheduler started") +} + +func (s *Scheduler) Stop() { + s.cron.Stop() +} + +func (s *Scheduler) checkNotifications() { + users, err := s.userRepo.GetUsersWithNotifications() + if err != nil { + log.Printf("Scheduler: error getting users: %v", err) + return + } + + for _, user := range users { + go s.checkUserNotifications(user) + } +} + +func (s *Scheduler) checkUserNotifications(user model.User) { + if !user.TelegramChatID.Valid { + return + } + + chatID := user.TelegramChatID.Int64 + + // Get user timezone + loc, err := time.LoadLocation(user.Timezone) + if err != nil { + loc = time.FixedZone("Europe/Moscow", 3*60*60) + } + + now := time.Now().In(loc) + currentTime := now.Format("15:04") + today := now.Format("2006-01-02") + weekday := int(now.Weekday()) + + // 1. Morning briefing at 09:00 + if currentTime == "09:00" { + s.sendMorningBriefing(user.ID, chatID, loc) + } + + // 2. Task reminders + s.checkTaskReminders(user.ID, chatID, currentTime, today) + + // 3. Habit reminders + s.checkHabitReminders(user.ID, chatID, currentTime, weekday) +} + +func (s *Scheduler) sendMorningBriefing(userID, chatID int64, loc *time.Location) { + tasks, err := s.taskRepo.GetTodayTasks(userID) + if err != nil { + log.Printf("Scheduler: error getting tasks for user %d: %v", userID, err) + return + } + + habits, err := s.habitRepo.ListByUser(userID, false) + if err != nil { + log.Printf("Scheduler: error getting habits for user %d: %v", userID, err) + return + } + + // Filter habits for today + weekday := int(time.Now().In(loc).Weekday()) + var todayHabits int + for _, habit := range habits { + if habit.Frequency == "daily" { + todayHabits++ + } else { + for _, day := range habit.TargetDays { + if day == weekday { + todayHabits++ + break + } + } + } + } + + if len(tasks) == 0 && todayHabits == 0 { + return // Nothing to report + } + + text := "☀️ Доброе утро!\n\n" + + if len(tasks) > 0 { + text += fmt.Sprintf("📋 Задач на сегодня: %d\n", len(tasks)) + for i, task := range tasks { + if i >= 3 { + text += fmt.Sprintf(" ... и ещё %d\n", len(tasks)-3) + break + } + text += fmt.Sprintf(" • %s %s\n", task.Icon, task.Title) + } + text += "\n" + } + + if todayHabits > 0 { + text += fmt.Sprintf("🎯 Привычек: %d\n", todayHabits) + } + + text += "\n/tasks — посмотреть задачи\n/habits — посмотреть привычки" + + s.bot.SendMessage(chatID, text) +} + +func (s *Scheduler) checkTaskReminders(userID, chatID int64, currentTime, today string) { + tasks, err := s.taskRepo.GetTasksWithReminder(currentTime, today) + if err != nil { + log.Printf("Scheduler: error getting task reminders: %v", err) + return + } + + for _, task := range tasks { + if task.UserID != userID { + continue + } + + text := fmt.Sprintf("⏰ Напоминание о задаче:\n\n%s %s", task.Icon, task.Title) + if task.Description != "" { + text += fmt.Sprintf("\n%s", task.Description) + } + text += fmt.Sprintf("\n\n/done_%d — отметить выполненной", task.ID) + + s.bot.SendMessage(chatID, text) + } +} + +func (s *Scheduler) checkHabitReminders(userID, chatID int64, currentTime string, weekday int) { + habits, err := s.habitRepo.GetHabitsWithReminder(currentTime, weekday) + if err != nil { + log.Printf("Scheduler: error getting habit reminders: %v", err) + return + } + + for _, habit := range habits { + if habit.UserID != userID { + continue + } + + // Check if already completed today + completed, _ := s.habitRepo.IsHabitCompletedToday(habit.ID, userID) + if completed { + continue + } + + text := fmt.Sprintf("⏰ Напоминание о привычке:\n\n%s %s", habit.Icon, habit.Name) + if habit.Description != "" { + text += fmt.Sprintf("\n%s", habit.Description) + } + text += fmt.Sprintf("\n\n/check_%d — отметить выполненной", habit.ID) + + s.bot.SendMessage(chatID, text) + } +} diff --git a/internal/service/auth.go b/internal/service/auth.go index c425dda..329ed2b 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -82,11 +82,6 @@ func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, error return nil, ErrInvalidCredentials } - // Optional: require email verification - // if !user.EmailVerified { - // return nil, ErrEmailNotVerified - // } - return s.generateAuthResponse(user) } @@ -110,7 +105,12 @@ func (s *AuthService) Refresh(refreshToken string) (*model.AuthResponse, error) } func (s *AuthService) GetUser(userID int64) (*model.User, error) { - return s.userRepo.GetByID(userID) + user, err := s.userRepo.GetByID(userID) + if err != nil { + return nil, err + } + user.ProcessForJSON() + return user, nil } func (s *AuthService) UpdateProfile(userID int64, req *model.UpdateProfileRequest) (*model.User, error) { @@ -119,14 +119,15 @@ func (s *AuthService) UpdateProfile(userID int64, req *model.UpdateProfileReques return nil, err } - if req.Username != "" { - user.Username = req.Username + if req.Username != nil && *req.Username != "" { + user.Username = *req.Username } if err := s.userRepo.Update(user); err != nil { return nil, err } + user.ProcessForJSON() return user, nil } @@ -248,6 +249,8 @@ func (s *AuthService) generateAuthResponse(user *model.User) (*model.AuthRespons return nil, err } + user.ProcessForJSON() + return &model.AuthResponse{ User: user, AccessToken: accessToken, diff --git a/internal/service/habit.go b/internal/service/habit.go index 12fcb58..488ae60 100644 --- a/internal/service/habit.go +++ b/internal/service/habit.go @@ -1,6 +1,7 @@ package service import ( + "database/sql" "time" "github.com/daniil/homelab-api/internal/model" @@ -27,10 +28,15 @@ func (s *HabitService) Create(userID int64, req *model.CreateHabitRequest) (*mod TargetCount: defaultInt(req.TargetCount, 1), } + if req.ReminderTime != nil && *req.ReminderTime != "" { + habit.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true} + } + if err := s.habitRepo.Create(habit); err != nil { return nil, err } + habit.ProcessForJSON() return habit, nil } @@ -76,6 +82,13 @@ func (s *HabitService) Update(id, userID int64, req *model.UpdateHabitRequest) ( if req.TargetCount != nil { habit.TargetCount = *req.TargetCount } + if req.ReminderTime != nil { + if *req.ReminderTime == "" { + habit.ReminderTime = sql.NullString{Valid: false} + } else { + habit.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true} + } + } if req.IsArchived != nil { habit.IsArchived = *req.IsArchived } @@ -84,6 +97,7 @@ func (s *HabitService) Update(id, userID int64, req *model.UpdateHabitRequest) ( return nil, err } + habit.ProcessForJSON() return habit, nil } diff --git a/internal/service/task.go b/internal/service/task.go index 4a5c993..1831ab9 100644 --- a/internal/service/task.go +++ b/internal/service/task.go @@ -33,6 +33,10 @@ func (s *TaskService) Create(userID int64, req *model.CreateTaskRequest) (*model } } + if req.ReminderTime != nil && *req.ReminderTime != "" { + task.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true} + } + if err := s.taskRepo.Create(task); err != nil { return nil, err } @@ -98,6 +102,13 @@ func (s *TaskService) Update(id, userID int64, req *model.UpdateTaskRequest) (*m } } } + if req.ReminderTime != nil { + if *req.ReminderTime == "" { + task.ReminderTime = sql.NullString{Valid: false} + } else { + task.ReminderTime = sql.NullString{String: *req.ReminderTime, Valid: true} + } + } if err := s.taskRepo.Update(task); err != nil { return nil, err