feat: add Telegram bot with notifications and scheduler

This commit is contained in:
Cosmo
2026-02-06 13:16:50 +00:00
parent 5a40127edd
commit 9e467b0448
19 changed files with 1007 additions and 110 deletions

View File

@@ -8,11 +8,13 @@ RUN apk add --no-cache git
# Copy go mod files # Copy go mod files
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download
# Copy source code # Copy source code first to allow go mod tidy
COPY . . COPY . .
# Update dependencies
RUN go mod tidy && go mod download
# Build # Build
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/api

View File

@@ -9,10 +9,12 @@ import (
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "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/config"
"github.com/daniil/homelab-api/internal/handler" "github.com/daniil/homelab-api/internal/handler"
customMiddleware "github.com/daniil/homelab-api/internal/middleware" customMiddleware "github.com/daniil/homelab-api/internal/middleware"
"github.com/daniil/homelab-api/internal/repository" "github.com/daniil/homelab-api/internal/repository"
"github.com/daniil/homelab-api/internal/scheduler"
"github.com/daniil/homelab-api/internal/service" "github.com/daniil/homelab-api/internal/service"
) )
@@ -43,11 +45,28 @@ func main() {
habitService := service.NewHabitService(habitRepo) habitService := service.NewHabitService(habitRepo)
taskService := service.NewTaskService(taskRepo) 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 // Initialize handlers
authHandler := handler.NewAuthHandler(authService) authHandler := handler.NewAuthHandler(authService)
habitHandler := handler.NewHabitHandler(habitService) habitHandler := handler.NewHabitHandler(habitService)
taskHandler := handler.NewTaskHandler(taskService) taskHandler := handler.NewTaskHandler(taskService)
healthHandler := handler.NewHealthHandler() healthHandler := handler.NewHealthHandler()
profileHandler := handler.NewProfileHandler(userRepo)
// Initialize middleware // Initialize middleware
authMiddleware := customMiddleware.NewAuthMiddleware(cfg.JWTSecret) authMiddleware := customMiddleware.NewAuthMiddleware(cfg.JWTSecret)
@@ -89,6 +108,10 @@ func main() {
r.Put("/auth/me", authHandler.UpdateProfile) r.Put("/auth/me", authHandler.UpdateProfile)
r.Put("/auth/password", authHandler.ChangePassword) r.Put("/auth/password", authHandler.ChangePassword)
// Profile routes
r.Get("/profile", profileHandler.Get)
r.Put("/profile", profileHandler.Update)
// Habits routes // Habits routes
r.Get("/habits", habitHandler.List) r.Get("/habits", habitHandler.List)
r.Post("/habits", habitHandler.Create) r.Post("/habits", habitHandler.Create)

View File

@@ -18,6 +18,7 @@ services:
- FROM_EMAIL=${FROM_EMAIL:-noreply@digital-home.site} - FROM_EMAIL=${FROM_EMAIL:-noreply@digital-home.site}
- FROM_NAME=${FROM_NAME:-Homelab} - FROM_NAME=${FROM_NAME:-Homelab}
- APP_URL=${APP_URL:-https://api.digital-home.site} - APP_URL=${APP_URL:-https://api.digital-home.site}
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy

2
go.mod
View File

@@ -5,8 +5,10 @@ go 1.22
require ( require (
github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/chi/v5 v5.0.12
github.com/go-chi/cors v1.2.1 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/golang-jwt/jwt/v5 v5.2.0
github.com/jmoiron/sqlx v1.3.5 github.com/jmoiron/sqlx v1.3.5
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/robfig/cron/v3 v3.0.1
golang.org/x/crypto v0.18.0 golang.org/x/crypto v0.18.0
) )

74
internal/bot/bot.go Normal file
View File

@@ -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
}

260
internal/bot/handlers.go Normal file
View File

@@ -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: <code>%d</code>
Скопируй его и вставь в настройках Pulse для получения уведомлений.
Доступные команды:
/tasks — задачи на сегодня
/habits — привычки на сегодня
/done &lt;id&gt; — отметить задачу выполненной
/check &lt;id&gt; — отметить привычку выполненной
/help — справка`, msg.Chat.ID)
b.SendMessage(msg.Chat.ID, text)
}
func (b *Bot) handleHelp(msg *tgbotapi.Message) {
text := `📚 <b>Справка по командам:</b>
/start — получить твой Chat ID
/tasks — список задач на сегодня
/habits — список привычек
/done &lt;id&gt; — отметить задачу выполненной
/check &lt;id&gt; — отметить привычку выполненной
💡 Чтобы получать уведомления, добавь свой 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 := "📋 <b>Задачи на сегодня:</b>\n\n"
for _, task := range tasks {
priority := ""
switch task.Priority {
case 3:
priority = "🔴 "
case 2:
priority = "🟡 "
case 1:
priority = "🔵 "
}
text += fmt.Sprintf("%s%s <b>%s</b>\n", priority, task.Icon, task.Title)
if task.Description != "" {
text += fmt.Sprintf(" <i>%s</i>\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 := "🎯 <b>Привычки на сегодня:</b>\n\n"
for _, habit := range todayHabits {
completed, _ := b.habitRepo.IsHabitCompletedToday(habit.ID, user.ID)
status := "⬜"
if completed {
status = "✅"
}
text += fmt.Sprintf("%s %s <b>%s</b>\n", status, habit.Icon, habit.Name)
if habit.Description != "" {
text += fmt.Sprintf(" <i>%s</i>\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 &lt;id&gt;")
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 &lt;id&gt;")
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, "✅ Привычка отмечена!")
}

View File

@@ -5,24 +5,26 @@ import (
) )
type Config struct { type Config struct {
DatabaseURL string DatabaseURL string
JWTSecret string JWTSecret string
Port string Port string
ResendAPIKey string ResendAPIKey string
FromEmail string FromEmail string
FromName string FromName string
AppURL string AppURL string
TelegramBotToken string
} }
func Load() *Config { func Load() *Config {
return &Config{ return &Config{
DatabaseURL: getEnv("DATABASE_URL", "postgres://homelab:homelab@db:5432/homelab?sslmode=disable"), DatabaseURL: getEnv("DATABASE_URL", "postgres://homelab:homelab@db:5432/homelab?sslmode=disable"),
JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"), JWTSecret: getEnv("JWT_SECRET", "change-me-in-production"),
Port: getEnv("PORT", "8080"), Port: getEnv("PORT", "8080"),
ResendAPIKey: getEnv("RESEND_API_KEY", ""), ResendAPIKey: getEnv("RESEND_API_KEY", ""),
FromEmail: getEnv("FROM_EMAIL", "noreply@digital-home.site"), FromEmail: getEnv("FROM_EMAIL", "noreply@digital-home.site"),
FromName: getEnv("FROM_NAME", "Homelab"), FromName: getEnv("FROM_NAME", "Homelab"),
AppURL: getEnv("APP_URL", "https://api.digital-home.site"), AppURL: getEnv("APP_URL", "https://api.digital-home.site"),
TelegramBotToken: getEnv("TELEGRAM_BOT_TOKEN", ""),
} }
} }

View File

@@ -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)
}

View File

@@ -1,22 +1,31 @@
package model package model
import ( import (
"database/sql"
"time" "time"
) )
type Habit struct { type Habit struct {
ID int64 `db:"id" json:"id"` ID int64 `db:"id" json:"id"`
UserID int64 `db:"user_id" json:"user_id"` UserID int64 `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
Description string `db:"description" json:"description"` Description string `db:"description" json:"description"`
Color string `db:"color" json:"color"` Color string `db:"color" json:"color"`
Icon string `db:"icon" json:"icon"` Icon string `db:"icon" json:"icon"`
Frequency string `db:"frequency" json:"frequency"` // daily, weekly, custom Frequency string `db:"frequency" json:"frequency"` // daily, weekly, custom
TargetDays []int `db:"-" json:"target_days"` // 0=Sun, 1=Mon, etc. TargetDays []int `db:"-" json:"target_days"` // 0=Sun, 1=Mon, etc.
TargetCount int `db:"target_count" json:"target_count"` TargetCount int `db:"target_count" json:"target_count"`
IsArchived bool `db:"is_archived" json:"is_archived"` ReminderTime sql.NullString `db:"reminder_time" json:"-"`
CreatedAt time.Time `db:"created_at" json:"created_at"` ReminderTimeStr *string `db:"-" json:"reminder_time"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` 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 { type HabitLog struct {
@@ -30,24 +39,26 @@ type HabitLog struct {
} }
type CreateHabitRequest struct { type CreateHabitRequest struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
Frequency string `json:"frequency,omitempty"` Frequency string `json:"frequency,omitempty"`
TargetDays []int `json:"target_days,omitempty"` TargetDays []int `json:"target_days,omitempty"`
TargetCount int `json:"target_count,omitempty"` TargetCount int `json:"target_count,omitempty"`
ReminderTime *string `json:"reminder_time,omitempty"`
} }
type UpdateHabitRequest struct { type UpdateHabitRequest struct {
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Color *string `json:"color,omitempty"` Color *string `json:"color,omitempty"`
Icon *string `json:"icon,omitempty"` Icon *string `json:"icon,omitempty"`
Frequency *string `json:"frequency,omitempty"` Frequency *string `json:"frequency,omitempty"`
TargetDays []int `json:"target_days,omitempty"` TargetDays []int `json:"target_days,omitempty"`
TargetCount *int `json:"target_count,omitempty"` TargetCount *int `json:"target_count,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"` ReminderTime *string `json:"reminder_time,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
} }
type LogHabitRequest struct { type LogHabitRequest struct {

View File

@@ -6,19 +6,21 @@ import (
) )
type Task struct { type Task struct {
ID int64 `db:"id" json:"id"` ID int64 `db:"id" json:"id"`
UserID int64 `db:"user_id" json:"user_id"` UserID int64 `db:"user_id" json:"user_id"`
Title string `db:"title" json:"title"` Title string `db:"title" json:"title"`
Description string `db:"description" json:"description"` Description string `db:"description" json:"description"`
Icon string `db:"icon" json:"icon"` Icon string `db:"icon" json:"icon"`
Color string `db:"color" json:"color"` Color string `db:"color" json:"color"`
DueDate sql.NullTime `db:"due_date" json:"-"` DueDate sql.NullTime `db:"due_date" json:"-"`
DueDateStr *string `db:"-" json:"due_date"` DueDateStr *string `db:"-" json:"due_date"`
Priority int `db:"priority" json:"priority"` // 0=none, 1=low, 2=medium, 3=high Priority int `db:"priority" json:"priority"` // 0=none, 1=low, 2=medium, 3=high
CompletedAt sql.NullTime `db:"completed_at" json:"-"` ReminderTime sql.NullString `db:"reminder_time" json:"-"`
Completed bool `db:"-" json:"completed"` ReminderTimeStr *string `db:"-" json:"reminder_time"`
CreatedAt time.Time `db:"created_at" json:"created_at"` CompletedAt sql.NullTime `db:"completed_at" json:"-"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` 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() { func (t *Task) ProcessForJSON() {
@@ -26,23 +28,28 @@ func (t *Task) ProcessForJSON() {
s := t.DueDate.Time.Format("2006-01-02") s := t.DueDate.Time.Format("2006-01-02")
t.DueDateStr = &s t.DueDateStr = &s
} }
if t.ReminderTime.Valid {
t.ReminderTimeStr = &t.ReminderTime.String
}
t.Completed = t.CompletedAt.Valid t.Completed = t.CompletedAt.Valid
} }
type CreateTaskRequest struct { type CreateTaskRequest struct {
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"` Icon string `json:"icon,omitempty"`
Color string `json:"color,omitempty"` Color string `json:"color,omitempty"`
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
Priority int `json:"priority,omitempty"` Priority int `json:"priority,omitempty"`
ReminderTime *string `json:"reminder_time,omitempty"` // HH:MM
} }
type UpdateTaskRequest struct { type UpdateTaskRequest struct {
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"` Icon *string `json:"icon,omitempty"`
Color *string `json:"color,omitempty"` Color *string `json:"color,omitempty"`
DueDate *string `json:"due_date,omitempty"` DueDate *string `json:"due_date,omitempty"`
Priority *int `json:"priority,omitempty"` Priority *int `json:"priority,omitempty"`
ReminderTime *string `json:"reminder_time,omitempty"`
} }

View File

@@ -1,17 +1,28 @@
package model package model
import ( import (
"database/sql"
"time" "time"
) )
type User struct { type User struct {
ID int64 `db:"id" json:"id"` ID int64 `db:"id" json:"id"`
Email string `db:"email" json:"email"` Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"` Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"-"` PasswordHash string `db:"password_hash" json:"-"`
EmailVerified bool `db:"email_verified" json:"email_verified"` EmailVerified bool `db:"email_verified" json:"email_verified"`
CreatedAt time.Time `db:"created_at" json:"created_at"` TelegramChatID sql.NullInt64 `db:"telegram_chat_id" json:"-"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"` 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 { type RegisterRequest struct {
@@ -26,7 +37,10 @@ type LoginRequest struct {
} }
type UpdateProfileRequest 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 { type ChangePasswordRequest struct {

View File

@@ -32,7 +32,7 @@ func RunMigrations(db *sqlx.DB) error {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
description TEXT DEFAULT '''', description TEXT DEFAULT '',
color VARCHAR(20) DEFAULT '#6366f1', color VARCHAR(20) DEFAULT '#6366f1',
icon VARCHAR(50) DEFAULT 'check', icon VARCHAR(50) DEFAULT 'check',
frequency VARCHAR(20) DEFAULT 'daily', frequency VARCHAR(20) DEFAULT 'daily',
@@ -48,7 +48,7 @@ func RunMigrations(db *sqlx.DB) error {
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL, date DATE NOT NULL,
count INTEGER DEFAULT 1, count INTEGER DEFAULT 1,
note TEXT DEFAULT '''', note TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(habit_id, date) UNIQUE(habit_id, date)
)`, )`,
@@ -65,7 +65,7 @@ func RunMigrations(db *sqlx.DB) error {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
description TEXT DEFAULT '''', description TEXT DEFAULT '',
icon VARCHAR(10) DEFAULT '📋', icon VARCHAR(10) DEFAULT '📋',
color VARCHAR(7) DEFAULT '#6B7280', color VARCHAR(7) DEFAULT '#6B7280',
due_date DATE, 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_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_due_date ON tasks(due_date)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(user_id, completed_at)`, `CREATE INDEX IF NOT EXISTS idx_tasks_completed ON tasks(user_id, completed_at)`,
// Migration: add email_verified column if not exists
`DO $$ `DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='email_verified') THEN 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; ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
END IF; END IF;
END $$;`, 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 { for _, migration := range migrations {

View File

@@ -23,8 +23,8 @@ func NewHabitRepository(db *sqlx.DB) *HabitRepository {
func (r *HabitRepository) Create(habit *model.Habit) error { func (r *HabitRepository) Create(habit *model.Habit) error {
query := ` query := `
INSERT INTO habits (user_id, name, description, color, icon, frequency, target_days, target_count) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, created_at, updated_at` RETURNING id, created_at, updated_at`
targetDays := pq.Array(habit.TargetDays) targetDays := pq.Array(habit.TargetDays)
@@ -41,6 +41,7 @@ func (r *HabitRepository) Create(habit *model.Habit) error {
habit.Frequency, habit.Frequency,
targetDays, targetDays,
habit.TargetCount, habit.TargetCount,
habit.ReminderTime,
).Scan(&habit.ID, &habit.CreatedAt, &habit.UpdatedAt) ).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 var targetDays pq.Int64Array
query := ` 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` FROM habits WHERE id = $1 AND user_id = $2`
err := r.db.QueryRow(query, id, userID).Scan( err := r.db.QueryRow(query, id, userID).Scan(
&habit.ID, &habit.UserID, &habit.Name, &habit.Description, &habit.ID, &habit.UserID, &habit.Name, &habit.Description,
&habit.Color, &habit.Icon, &habit.Frequency, &targetDays, &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 { if err != nil {
@@ -69,13 +70,14 @@ func (r *HabitRepository) GetByID(id, userID int64) (*model.Habit, error) {
for i, v := range targetDays { for i, v := range targetDays {
habit.TargetDays[i] = int(v) habit.TargetDays[i] = int(v)
} }
habit.ProcessForJSON()
return &habit, nil return &habit, nil
} }
func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]model.Habit, error) { func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]model.Habit, error) {
query := ` 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` FROM habits WHERE user_id = $1`
if !includeArchived { if !includeArchived {
@@ -97,7 +99,7 @@ func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]mode
if err := rows.Scan( if err := rows.Scan(
&habit.ID, &habit.UserID, &habit.Name, &habit.Description, &habit.ID, &habit.UserID, &habit.Name, &habit.Description,
&habit.Color, &habit.Icon, &habit.Frequency, &targetDays, &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 { ); err != nil {
return nil, err return nil, err
} }
@@ -106,6 +108,49 @@ func (r *HabitRepository) ListByUser(userID int64, includeArchived bool) ([]mode
for i, v := range targetDays { for i, v := range targetDays {
habit.TargetDays[i] = int(v) 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) habits = append(habits, habit)
} }
@@ -117,8 +162,8 @@ func (r *HabitRepository) Update(habit *model.Habit) error {
query := ` query := `
UPDATE habits UPDATE habits
SET name = $2, description = $3, color = $4, icon = $5, frequency = $6, SET name = $2, description = $3, color = $4, icon = $5, frequency = $6,
target_days = $7, target_count = $8, is_archived = $9, updated_at = CURRENT_TIMESTAMP target_days = $7, target_count = $8, reminder_time = $9, is_archived = $10, updated_at = CURRENT_TIMESTAMP
WHERE id = $1 AND user_id = $10 WHERE id = $1 AND user_id = $11
RETURNING updated_at` RETURNING updated_at`
return r.db.QueryRow(query, return r.db.QueryRow(query,
@@ -130,6 +175,7 @@ func (r *HabitRepository) Update(habit *model.Habit) error {
habit.Frequency, habit.Frequency,
pq.Array(habit.TargetDays), pq.Array(habit.TargetDays),
habit.TargetCount, habit.TargetCount,
habit.ReminderTime,
habit.IsArchived, habit.IsArchived,
habit.UserID, habit.UserID,
).Scan(&habit.UpdatedAt) ).Scan(&habit.UpdatedAt)
@@ -208,6 +254,13 @@ func (r *HabitRepository) GetUserLogsForDate(userID int64, date time.Time) ([]mo
return logs, nil 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) { func (r *HabitRepository) GetStats(habitID, userID int64) (*model.HabitStats, error) {
stats := &model.HabitStats{HabitID: habitID} stats := &model.HabitStats{HabitID: habitID}

View File

@@ -21,8 +21,8 @@ func NewTaskRepository(db *sqlx.DB) *TaskRepository {
func (r *TaskRepository) Create(task *model.Task) error { func (r *TaskRepository) Create(task *model.Task) error {
query := ` query := `
INSERT INTO tasks (user_id, title, description, icon, color, due_date, priority) INSERT INTO tasks (user_id, title, description, icon, color, due_date, priority, reminder_time)
VALUES ($1, $2, $3, $4, $5, $6, $7) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at, updated_at` RETURNING id, created_at, updated_at`
return r.db.QueryRow(query, return r.db.QueryRow(query,
@@ -33,6 +33,7 @@ func (r *TaskRepository) Create(task *model.Task) error {
task.Color, task.Color,
task.DueDate, task.DueDate,
task.Priority, task.Priority,
task.ReminderTime,
).Scan(&task.ID, &task.CreatedAt, &task.UpdatedAt) ).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 var task model.Task
query := ` 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` FROM tasks WHERE id = $1 AND user_id = $2`
err := r.db.QueryRow(query, id, userID).Scan( err := r.db.QueryRow(query, id, userID).Scan(
&task.ID, &task.UserID, &task.Title, &task.Description, &task.ID, &task.UserID, &task.Title, &task.Description,
&task.Icon, &task.Color, &task.DueDate, &task.Priority, &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 { 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) { func (r *TaskRepository) ListByUser(userID int64, completed *bool) ([]model.Task, error) {
query := ` 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` FROM tasks WHERE user_id = $1`
if completed != nil { if completed != nil {
@@ -87,7 +88,7 @@ func (r *TaskRepository) ListByUser(userID int64, completed *bool) ([]model.Task
if err := rows.Scan( if err := rows.Scan(
&task.ID, &task.UserID, &task.Title, &task.Description, &task.ID, &task.UserID, &task.Title, &task.Description,
&task.Icon, &task.Color, &task.DueDate, &task.Priority, &task.Icon, &task.Color, &task.DueDate, &task.Priority,
&task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, &task.ReminderTime, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -103,7 +104,7 @@ func (r *TaskRepository) GetTodayTasks(userID int64) ([]model.Task, error) {
today := time.Now().Format("2006-01-02") today := time.Now().Format("2006-01-02")
query := ` 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 FROM tasks
WHERE user_id = $1 AND completed_at IS NULL AND due_date <= $2 WHERE user_id = $1 AND completed_at IS NULL AND due_date <= $2
ORDER BY priority DESC, due_date, created_at` 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( if err := rows.Scan(
&task.ID, &task.UserID, &task.Title, &task.Description, &task.ID, &task.UserID, &task.Title, &task.Description,
&task.Icon, &task.Color, &task.DueDate, &task.Priority, &task.Icon, &task.Color, &task.DueDate, &task.Priority,
&task.CompletedAt, &task.CreatedAt, &task.UpdatedAt, &task.ReminderTime, &task.CompletedAt, &task.CreatedAt, &task.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@@ -133,11 +134,45 @@ func (r *TaskRepository) GetTodayTasks(userID int64) ([]model.Task, error) {
return tasks, nil 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 { func (r *TaskRepository) Update(task *model.Task) error {
query := ` query := `
UPDATE tasks UPDATE tasks
SET title = $2, description = $3, icon = $4, color = $5, due_date = $6, priority = $7, updated_at = CURRENT_TIMESTAMP 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 = $8 WHERE id = $1 AND user_id = $9
RETURNING updated_at` RETURNING updated_at`
return r.db.QueryRow(query, return r.db.QueryRow(query,
@@ -148,6 +183,7 @@ func (r *TaskRepository) Update(task *model.Task) error {
task.Color, task.Color,
task.DueDate, task.DueDate,
task.Priority, task.Priority,
task.ReminderTime,
task.UserID, task.UserID,
).Scan(&task.UpdatedAt) ).Scan(&task.UpdatedAt)
} }

View File

@@ -3,6 +3,7 @@ package repository
import ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"github.com/daniil/homelab-api/internal/model" "github.com/daniil/homelab-api/internal/model"
"github.com/jmoiron/sqlx" "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) { func (r *UserRepository) GetByID(id int64) (*model.User, error) {
var user model.User 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound 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) { func (r *UserRepository) GetByEmail(email string) (*model.User, error) {
var user model.User 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound return nil, ErrUserNotFound
} }
@@ -66,6 +89,64 @@ func (r *UserRepository) GetByEmail(email string) (*model.User, error) {
return &user, nil 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 { func (r *UserRepository) Update(user *model.User) error {
query := ` query := `
UPDATE users 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) 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 { func (r *UserRepository) UpdatePassword(id int64, passwordHash string) error {
query := `UPDATE users SET password_hash = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1` query := `UPDATE users SET password_hash = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`
_, err := r.db.Exec(query, id, passwordHash) _, err := r.db.Exec(query, id, passwordHash)

View File

@@ -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 := "☀️ <b>Доброе утро!</b>\n\n"
if len(tasks) > 0 {
text += fmt.Sprintf("📋 Задач на сегодня: <b>%d</b>\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("🎯 Привычек: <b>%d</b>\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("⏰ <b>Напоминание о задаче:</b>\n\n%s <b>%s</b>", task.Icon, task.Title)
if task.Description != "" {
text += fmt.Sprintf("\n<i>%s</i>", 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("⏰ <b>Напоминание о привычке:</b>\n\n%s <b>%s</b>", habit.Icon, habit.Name)
if habit.Description != "" {
text += fmt.Sprintf("\n<i>%s</i>", habit.Description)
}
text += fmt.Sprintf("\n\n/check_%d — отметить выполненной", habit.ID)
s.bot.SendMessage(chatID, text)
}
}

View File

@@ -82,11 +82,6 @@ func (s *AuthService) Login(req *model.LoginRequest) (*model.AuthResponse, error
return nil, ErrInvalidCredentials return nil, ErrInvalidCredentials
} }
// Optional: require email verification
// if !user.EmailVerified {
// return nil, ErrEmailNotVerified
// }
return s.generateAuthResponse(user) 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) { 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) { 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 return nil, err
} }
if req.Username != "" { if req.Username != nil && *req.Username != "" {
user.Username = req.Username user.Username = *req.Username
} }
if err := s.userRepo.Update(user); err != nil { if err := s.userRepo.Update(user); err != nil {
return nil, err return nil, err
} }
user.ProcessForJSON()
return user, nil return user, nil
} }
@@ -248,6 +249,8 @@ func (s *AuthService) generateAuthResponse(user *model.User) (*model.AuthRespons
return nil, err return nil, err
} }
user.ProcessForJSON()
return &model.AuthResponse{ return &model.AuthResponse{
User: user, User: user,
AccessToken: accessToken, AccessToken: accessToken,

View File

@@ -1,6 +1,7 @@
package service package service
import ( import (
"database/sql"
"time" "time"
"github.com/daniil/homelab-api/internal/model" "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), 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 { if err := s.habitRepo.Create(habit); err != nil {
return nil, err return nil, err
} }
habit.ProcessForJSON()
return habit, nil return habit, nil
} }
@@ -76,6 +82,13 @@ func (s *HabitService) Update(id, userID int64, req *model.UpdateHabitRequest) (
if req.TargetCount != nil { if req.TargetCount != nil {
habit.TargetCount = *req.TargetCount 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 { if req.IsArchived != nil {
habit.IsArchived = *req.IsArchived habit.IsArchived = *req.IsArchived
} }
@@ -84,6 +97,7 @@ func (s *HabitService) Update(id, userID int64, req *model.UpdateHabitRequest) (
return nil, err return nil, err
} }
habit.ProcessForJSON()
return habit, nil return habit, nil
} }

View File

@@ -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 { if err := s.taskRepo.Create(task); err != nil {
return nil, err 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 { if err := s.taskRepo.Update(task); err != nil {
return nil, err return nil, err