package main import ( "bytes" "context" "crypto/rand" "database/sql" "encoding/base64" "encoding/json" "fmt" "io" "log" "math" "net/http" "net/url" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" "unicode/utf16" "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" "github.com/joho/godotenv" _ "github.com/lib/pq" "github.com/lib/pq" "github.com/robfig/cron/v3" "golang.org/x/crypto/bcrypt" ) type Word struct { ID int `json:"id"` Name string `json:"name"` Translation string `json:"translation"` Description string `json:"description"` Success int `json:"success"` Failure int `json:"failure"` LastSuccess *string `json:"last_success_at,omitempty"` LastFailure *string `json:"last_failure_at,omitempty"` } type WordRequest struct { Name string `json:"name"` Translation string `json:"translation"` Description string `json:"description"` DictionaryID *int `json:"dictionary_id,omitempty"` } type WordsRequest struct { Words []WordRequest `json:"words"` } type TestProgressUpdate struct { ID int `json:"id"` Success int `json:"success"` Failure int `json:"failure"` LastSuccessAt *string `json:"last_success_at,omitempty"` LastFailureAt *string `json:"last_failure_at,omitempty"` } type TestProgressRequest struct { Words []TestProgressUpdate `json:"words"` ConfigID *int `json:"config_id,omitempty"` } type Config struct { ID int `json:"id"` Name string `json:"name"` WordsCount int `json:"words_count"` MaxCards *int `json:"max_cards,omitempty"` TryMessage string `json:"try_message"` } type ConfigRequest struct { Name string `json:"name"` WordsCount int `json:"words_count"` MaxCards *int `json:"max_cards,omitempty"` TryMessage string `json:"try_message"` DictionaryIDs []int `json:"dictionary_ids,omitempty"` } type Dictionary struct { ID int `json:"id"` Name string `json:"name"` WordsCount int `json:"wordsCount"` } type DictionaryRequest struct { Name string `json:"name"` } type TestConfigsAndDictionariesResponse struct { Configs []Config `json:"configs"` Dictionaries []Dictionary `json:"dictionaries"` } type WeeklyProjectStats struct { ProjectName string `json:"project_name"` TotalScore float64 `json:"total_score"` MinGoalScore float64 `json:"min_goal_score"` MaxGoalScore *float64 `json:"max_goal_score,omitempty"` Priority *int `json:"priority,omitempty"` CalculatedScore float64 `json:"calculated_score"` } type WeeklyStatsResponse struct { Total *float64 `json:"total,omitempty"` Projects []WeeklyProjectStats `json:"projects"` } type MessagePostRequest struct { Body struct { Text string `json:"text"` } `json:"body"` } type ProcessedNode struct { Project string `json:"project"` Score float64 `json:"score"` } type ProcessedEntry struct { Text string `json:"text"` CreatedDate string `json:"createdDate"` Nodes []ProcessedNode `json:"nodes"` Raw string `json:"raw"` Markdown string `json:"markdown"` } type WeeklyGoalSetup struct { ProjectName string `json:"project_name"` MinGoalScore float64 `json:"min_goal_score"` MaxGoalScore float64 `json:"max_goal_score"` } type Project struct { ProjectID int `json:"project_id"` ProjectName string `json:"project_name"` Priority *int `json:"priority,omitempty"` } type ProjectPriorityUpdate struct { ID int `json:"id"` Priority *int `json:"priority"` } type ProjectPriorityRequest struct { Body []ProjectPriorityUpdate `json:"body"` } type FullStatisticsItem struct { ProjectName string `json:"project_name"` ReportYear int `json:"report_year"` ReportWeek int `json:"report_week"` TotalScore float64 `json:"total_score"` MinGoalScore float64 `json:"min_goal_score"` MaxGoalScore float64 `json:"max_goal_score"` } type TodoistWebhook struct { EventName string `json:"event_name"` EventData map[string]interface{} `json:"event_data"` } type TelegramEntity struct { Type string `json:"type"` Offset int `json:"offset"` Length int `json:"length"` } type TelegramChat struct { ID int64 `json:"id"` } type TelegramUser struct { ID int64 `json:"id"` } type TelegramMessage struct { Text string `json:"text"` Entities []TelegramEntity `json:"entities"` Chat TelegramChat `json:"chat"` From *TelegramUser `json:"from,omitempty"` } type TelegramWebhook struct { Message TelegramMessage `json:"message"` } // TelegramUpdate - структура для Telegram webhook (обычно это Update объект) type TelegramUpdate struct { UpdateID int `json:"update_id"` Message *TelegramMessage `json:"message,omitempty"` EditedMessage *TelegramMessage `json:"edited_message,omitempty"` } // Task structures type Task struct { ID int `json:"id"` Name string `json:"name"` Completed int `json:"completed"` LastCompletedAt *string `json:"last_completed_at,omitempty"` NextShowAt *string `json:"next_show_at,omitempty"` RewardMessage *string `json:"reward_message,omitempty"` ProgressionBase *float64 `json:"progression_base,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionDate *string `json:"repetition_date,omitempty"` // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) ProjectNames []string `json:"project_names"` SubtasksCount int `json:"subtasks_count"` HasProgression bool `json:"has_progression"` } type Reward struct { ID int `json:"id"` Position int `json:"position"` ProjectName string `json:"project_name"` Value float64 `json:"value"` UseProgression bool `json:"use_progression"` } type Subtask struct { Task Task `json:"task"` Rewards []Reward `json:"rewards"` } type TaskDetail struct { Task Task `json:"task"` Rewards []Reward `json:"rewards"` Subtasks []Subtask `json:"subtasks"` } type RewardRequest struct { Position int `json:"position"` ProjectName string `json:"project_name"` Value float64 `json:"value"` UseProgression bool `json:"use_progression"` } type SubtaskRequest struct { ID *int `json:"id,omitempty"` Name *string `json:"name,omitempty"` RewardMessage *string `json:"reward_message,omitempty"` Rewards []RewardRequest `json:"rewards,omitempty"` } type TaskRequest struct { Name string `json:"name"` ProgressionBase *float64 `json:"progression_base,omitempty"` RewardMessage *string `json:"reward_message,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionDate *string `json:"repetition_date,omitempty"` Rewards []RewardRequest `json:"rewards,omitempty"` Subtasks []SubtaskRequest `json:"subtasks,omitempty"` } type CompleteTaskRequest struct { Value *float64 `json:"value,omitempty"` ChildrenTaskIDs []int `json:"children_task_ids,omitempty"` } type PostponeTaskRequest struct { NextShowAt *string `json:"next_show_at"` } // ============================================ // Helper functions for repetition_date // ============================================ // calculateNextShowAtFromRepetitionDate calculates the next occurrence date based on repetition_date pattern // Formats: // - "N week" - Nth day of week (1=Monday, 7=Sunday) // - "N month" - Nth day of month (1-31) // - "MM-DD year" - specific date each year func calculateNextShowAtFromRepetitionDate(repetitionDate string, fromDate time.Time) *time.Time { if repetitionDate == "" { return nil } parts := strings.Fields(strings.TrimSpace(repetitionDate)) if len(parts) < 2 { return nil } value := parts[0] unit := strings.ToLower(parts[1]) // Start from tomorrow at midnight nextDate := time.Date(fromDate.Year(), fromDate.Month(), fromDate.Day(), 0, 0, 0, 0, fromDate.Location()) nextDate = nextDate.AddDate(0, 0, 1) switch unit { case "week": // N-th day of week (1=Monday, 7=Sunday) dayOfWeek, err := strconv.Atoi(value) if err != nil || dayOfWeek < 1 || dayOfWeek > 7 { return nil } // Go: Sunday=0, Monday=1, ..., Saturday=6 // Our format: Monday=1, ..., Sunday=7 // Convert our format to Go format targetGoDay := dayOfWeek % 7 // Monday(1)->1, Sunday(7)->0 currentGoDay := int(nextDate.Weekday()) daysUntil := (targetGoDay - currentGoDay + 7) % 7 if daysUntil == 0 { daysUntil = 7 // If same day, go to next week } nextDate = nextDate.AddDate(0, 0, daysUntil) case "month": // N-th day of month dayOfMonth, err := strconv.Atoi(value) if err != nil || dayOfMonth < 1 || dayOfMonth > 31 { return nil } // Find the next occurrence of this day for i := 0; i < 12; i++ { // Check up to 12 months ahead // Get the last day of the current month year, month, _ := nextDate.Date() lastDayOfMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, nextDate.Location()).Day() // Use the actual day (capped at last day of month if needed) actualDay := dayOfMonth if actualDay > lastDayOfMonth { actualDay = lastDayOfMonth } candidateDate := time.Date(year, month, actualDay, 0, 0, 0, 0, nextDate.Location()) // If this date is in the future (after fromDate), use it if candidateDate.After(fromDate) { nextDate = candidateDate break } // Otherwise, try next month nextDate = time.Date(year, month+1, 1, 0, 0, 0, 0, nextDate.Location()) } case "year": // MM-DD format (e.g., "02-01" for February 1st) dateParts := strings.Split(value, "-") if len(dateParts) != 2 { return nil } month, err1 := strconv.Atoi(dateParts[0]) day, err2 := strconv.Atoi(dateParts[1]) if err1 != nil || err2 != nil || month < 1 || month > 12 || day < 1 || day > 31 { return nil } // Find the next occurrence of this date year := nextDate.Year() candidateDate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, nextDate.Location()) // If this year's date has passed, use next year if !candidateDate.After(fromDate) { candidateDate = time.Date(year+1, time.Month(month), day, 0, 0, 0, 0, nextDate.Location()) } nextDate = candidateDate default: return nil } return &nextDate } // calculateNextShowAtFromRepetitionPeriod calculates the next show date by adding repetition_period to fromDate // Format: PostgreSQL INTERVAL string (e.g., "1 day", "2 weeks", "3 months" or "3 mons") // Note: PostgreSQL may return weeks as days (e.g., "7 days" instead of "1 week") func calculateNextShowAtFromRepetitionPeriod(repetitionPeriod string, fromDate time.Time) *time.Time { if repetitionPeriod == "" { return nil } parts := strings.Fields(strings.TrimSpace(repetitionPeriod)) if len(parts) < 2 { log.Printf("calculateNextShowAtFromRepetitionPeriod: invalid format, parts=%v", parts) return nil } value, err := strconv.Atoi(parts[0]) if err != nil { log.Printf("calculateNextShowAtFromRepetitionPeriod: failed to parse value '%s': %v", parts[0], err) return nil } unit := strings.ToLower(parts[1]) log.Printf("calculateNextShowAtFromRepetitionPeriod: value=%d, unit='%s'", value, unit) // Start from fromDate at midnight nextDate := time.Date(fromDate.Year(), fromDate.Month(), fromDate.Day(), 0, 0, 0, 0, fromDate.Location()) switch unit { case "minute", "minutes", "mins", "min": nextDate = nextDate.Add(time.Duration(value) * time.Minute) case "hour", "hours", "hrs", "hr": nextDate = nextDate.Add(time.Duration(value) * time.Hour) case "day", "days": // PostgreSQL может возвращать недели как дни (например, "7 days" вместо "1 week") // Если количество дней кратно 7, обрабатываем как недели if value%7 == 0 && value >= 7 { weeks := value / 7 nextDate = nextDate.AddDate(0, 0, weeks*7) } else { nextDate = nextDate.AddDate(0, 0, value) } case "week", "weeks", "wks", "wk": nextDate = nextDate.AddDate(0, 0, value*7) case "month", "months", "mons", "mon": nextDate = nextDate.AddDate(0, value, 0) log.Printf("calculateNextShowAtFromRepetitionPeriod: added %d months, result=%v", value, nextDate) case "year", "years", "yrs", "yr": nextDate = nextDate.AddDate(value, 0, 0) default: log.Printf("calculateNextShowAtFromRepetitionPeriod: unknown unit '%s'", unit) return nil } return &nextDate } // ============================================ // Auth types // ============================================ type User struct { ID int `json:"id"` Email string `json:"email"` Name *string `json:"name,omitempty"` PasswordHash string `json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` IsActive bool `json:"is_active"` LastLoginAt *time.Time `json:"last_login_at,omitempty"` } type LoginRequest struct { Email string `json:"email"` Password string `json:"password"` } type RegisterRequest struct { Email string `json:"email"` Password string `json:"password"` Name *string `json:"name,omitempty"` } type TokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` User User `json:"user"` } type RefreshRequest struct { RefreshToken string `json:"refresh_token"` } type UserResponse struct { User User `json:"user"` } type JWTClaims struct { UserID int `json:"user_id"` jwt.RegisteredClaims } // Context key for user ID type contextKey string const userIDKey contextKey = "user_id" type App struct { DB *sql.DB webhookMutex sync.Mutex lastWebhookTime map[int]time.Time // config_id -> last webhook time telegramBot *tgbotapi.BotAPI telegramBotUsername string jwtSecret []byte } func setCORSHeaders(w http.ResponseWriter) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") } // ============================================ // Auth helper functions // ============================================ func hashPassword(password string) (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) return string(bytes), err } func checkPasswordHash(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } func generateRefreshToken() (string, error) { b := make([]byte, 32) _, err := rand.Read(b) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } // generateWebhookToken generates a unique token for webhook URL identification func generateWebhookToken() (string, error) { b := make([]byte, 24) // 24 bytes = 32 chars in base64 _, err := rand.Read(b) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } func (a *App) generateAccessToken(userID int) (string, error) { claims := JWTClaims{ UserID: userID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(a.jwtSecret) } func (a *App) validateAccessToken(tokenString string) (*JWTClaims, error) { token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return a.jwtSecret, nil }) if err != nil { return nil, err } if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { return claims, nil } return nil, fmt.Errorf("invalid token") } // getUserIDFromContext extracts user ID from request context func getUserIDFromContext(r *http.Request) (int, bool) { userID, ok := r.Context().Value(userIDKey).(int) return userID, ok } // ============================================ // Auth middleware // ============================================ func (a *App) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Handle CORS preflight if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } authHeader := r.Header.Get("Authorization") if authHeader == "" { sendErrorWithCORS(w, "Authorization header required", http.StatusUnauthorized) return } parts := strings.Split(authHeader, " ") if len(parts) != 2 || parts[0] != "Bearer" { sendErrorWithCORS(w, "Invalid authorization header format", http.StatusUnauthorized) return } claims, err := a.validateAccessToken(parts[1]) if err != nil { sendErrorWithCORS(w, "Invalid or expired token", http.StatusUnauthorized) return } // Add user_id to context ctx := context.WithValue(r.Context(), userIDKey, claims.UserID) next.ServeHTTP(w, r.WithContext(ctx)) }) } // ============================================ // Auth handlers // ============================================ func (a *App) registerHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) var req RegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.Email == "" || req.Password == "" { sendErrorWithCORS(w, "Email and password are required", http.StatusBadRequest) return } if len(req.Password) < 6 { sendErrorWithCORS(w, "Password must be at least 6 characters", http.StatusBadRequest) return } // Check if email already exists var existingID int err := a.DB.QueryRow("SELECT id FROM users WHERE email = $1", req.Email).Scan(&existingID) if err == nil { sendErrorWithCORS(w, "Email already registered", http.StatusConflict) return } if err != sql.ErrNoRows { log.Printf("Error checking existing user: %v", err) sendErrorWithCORS(w, "Database error", http.StatusInternalServerError) return } // Hash password passwordHash, err := hashPassword(req.Password) if err != nil { log.Printf("Error hashing password: %v", err) sendErrorWithCORS(w, "Error processing password", http.StatusInternalServerError) return } // Insert user var user User err = a.DB.QueryRow(` INSERT INTO users (email, password_hash, name, created_at, updated_at, is_active) VALUES ($1, $2, $3, NOW(), NOW(), true) RETURNING id, email, name, created_at, updated_at, is_active, last_login_at `, req.Email, passwordHash, req.Name).Scan( &user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.LastLoginAt, ) if err != nil { log.Printf("Error inserting user: %v", err) sendErrorWithCORS(w, "Error creating user", http.StatusInternalServerError) return } // Check if this is the first user - if so, claim all orphaned data var userCount int a.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount) if userCount == 1 { log.Printf("First user registered (ID: %d), claiming all orphaned data", user.ID) a.claimOrphanedData(user.ID) } // Generate tokens accessToken, err := a.generateAccessToken(user.ID) if err != nil { log.Printf("Error generating access token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } refreshToken, err := generateRefreshToken() if err != nil { log.Printf("Error generating refresh token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } // Hash and store refresh token refreshTokenHash, _ := hashPassword(refreshToken) _, err = a.DB.Exec(` INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3) `, user.ID, refreshTokenHash, nil) if err != nil { log.Printf("Error storing refresh token: %v", err) } // Update last login a.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = $1", user.ID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(TokenResponse{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: 86400, // 24 hours User: user, }) } func (a *App) loginHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.Email == "" || req.Password == "" { sendErrorWithCORS(w, "Email and password are required", http.StatusBadRequest) return } // Find user var user User err := a.DB.QueryRow(` SELECT id, email, password_hash, name, created_at, updated_at, is_active, last_login_at FROM users WHERE email = $1 `, req.Email).Scan( &user.ID, &user.Email, &user.PasswordHash, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.LastLoginAt, ) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Invalid email or password", http.StatusUnauthorized) return } if err != nil { log.Printf("Error finding user: %v", err) sendErrorWithCORS(w, "Database error", http.StatusInternalServerError) return } if !user.IsActive { sendErrorWithCORS(w, "Account is disabled", http.StatusForbidden) return } // Check password if !checkPasswordHash(req.Password, user.PasswordHash) { sendErrorWithCORS(w, "Invalid email or password", http.StatusUnauthorized) return } // Check if there is any orphaned data - claim it for this user var orphanedDataCount int a.DB.QueryRow(` SELECT COUNT(*) FROM ( SELECT 1 FROM projects WHERE user_id IS NULL UNION ALL SELECT 1 FROM entries WHERE user_id IS NULL UNION ALL SELECT 1 FROM nodes WHERE user_id IS NULL UNION ALL SELECT 1 FROM dictionaries WHERE user_id IS NULL UNION ALL SELECT 1 FROM words WHERE user_id IS NULL UNION ALL SELECT 1 FROM progress WHERE user_id IS NULL UNION ALL SELECT 1 FROM configs WHERE user_id IS NULL UNION ALL SELECT 1 FROM telegram_integrations WHERE user_id IS NULL UNION ALL SELECT 1 FROM weekly_goals WHERE user_id IS NULL LIMIT 1 ) orphaned `).Scan(&orphanedDataCount) if orphanedDataCount > 0 { log.Printf("User %d logging in, claiming orphaned data from all tables", user.ID) a.claimOrphanedData(user.ID) } // Generate tokens accessToken, err := a.generateAccessToken(user.ID) if err != nil { log.Printf("Error generating access token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } refreshToken, err := generateRefreshToken() if err != nil { log.Printf("Error generating refresh token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } // Hash and store refresh token refreshTokenHash, _ := hashPassword(refreshToken) _, err = a.DB.Exec(` INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3) `, user.ID, refreshTokenHash, nil) if err != nil { log.Printf("Error storing refresh token: %v", err) } // Update last login a.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = $1", user.ID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(TokenResponse{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: 86400, // 24 hours User: user, }) } func (a *App) refreshTokenHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) var req RefreshRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.RefreshToken == "" { sendErrorWithCORS(w, "Refresh token is required", http.StatusBadRequest) return } // Find valid refresh token (expires_at is NULL for tokens without expiration) rows, err := a.DB.Query(` SELECT rt.id, rt.user_id, rt.token_hash, u.email, u.name, u.created_at, u.updated_at, u.is_active, u.last_login_at FROM refresh_tokens rt JOIN users u ON rt.user_id = u.id WHERE rt.expires_at IS NULL OR rt.expires_at > NOW() `) if err != nil { log.Printf("Error querying refresh tokens: %v", err) sendErrorWithCORS(w, "Database error", http.StatusInternalServerError) return } defer rows.Close() var foundTokenID int var user User var tokenFound bool for rows.Next() { var tokenID int var tokenHash string err := rows.Scan(&tokenID, &user.ID, &tokenHash, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.LastLoginAt) if err != nil { continue } if checkPasswordHash(req.RefreshToken, tokenHash) { foundTokenID = tokenID tokenFound = true break } } if !tokenFound { sendErrorWithCORS(w, "Invalid or expired refresh token", http.StatusUnauthorized) return } if !user.IsActive { sendErrorWithCORS(w, "Account is disabled", http.StatusForbidden) return } // Delete old refresh token a.DB.Exec("DELETE FROM refresh_tokens WHERE id = $1", foundTokenID) // Generate new tokens accessToken, err := a.generateAccessToken(user.ID) if err != nil { log.Printf("Error generating access token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } refreshToken, err := generateRefreshToken() if err != nil { log.Printf("Error generating refresh token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } // Store new refresh token refreshTokenHash, _ := hashPassword(refreshToken) a.DB.Exec(` INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3) `, user.ID, refreshTokenHash, nil) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(TokenResponse{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: 86400, // 24 hours User: user, }) } func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Delete all refresh tokens for this user a.DB.Exec("DELETE FROM refresh_tokens WHERE user_id = $1", userID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"}) } func (a *App) getMeHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var user User err := a.DB.QueryRow(` SELECT id, email, name, created_at, updated_at, is_active, last_login_at FROM users WHERE id = $1 `, userID).Scan( &user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.LastLoginAt, ) if err != nil { log.Printf("Error finding user: %v", err) sendErrorWithCORS(w, "User not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(UserResponse{User: user}) } // claimOrphanedData assigns all data with NULL user_id to the specified user func (a *App) claimOrphanedData(userID int) { tables := []string{"projects", "entries", "nodes", "dictionaries", "words", "progress", "configs", "telegram_integrations", "weekly_goals"} for _, table := range tables { // First check if user_id column exists var columnExists bool err := a.DB.QueryRow(` SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'user_id' ) `, table).Scan(&columnExists) if err != nil || !columnExists { log.Printf("Skipping %s: user_id column does not exist (run migrations as table owner)", table) continue } result, err := a.DB.Exec(fmt.Sprintf("UPDATE %s SET user_id = $1 WHERE user_id IS NULL", table), userID) if err != nil { log.Printf("Error claiming orphaned data in %s: %v", table, err) } else { rowsAffected, _ := result.RowsAffected() if rowsAffected > 0 { log.Printf("Claimed %d orphaned rows in %s for user %d", rowsAffected, table, userID) } } } } func sendErrorWithCORS(w http.ResponseWriter, message string, statusCode int) { setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) json.NewEncoder(w).Encode(map[string]interface{}{ "error": message, }) } func (a *App) getWordsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Get dictionary_id from query parameter dictionaryIDStr := r.URL.Query().Get("dictionary_id") var dictionaryID *int if dictionaryIDStr != "" { if id, err := strconv.Atoi(dictionaryIDStr); err == nil { dictionaryID = &id } } query := ` SELECT w.id, w.name, w.translation, w.description, COALESCE(p.success, 0) as success, COALESCE(p.failure, 0) as failure, CASE WHEN p.last_success_at IS NOT NULL THEN p.last_success_at::text ELSE NULL END as last_success_at, CASE WHEN p.last_failure_at IS NOT NULL THEN p.last_failure_at::text ELSE NULL END as last_failure_at FROM words w JOIN dictionaries d ON w.dictionary_id = d.id LEFT JOIN progress p ON w.id = p.word_id AND p.user_id = $1 WHERE d.user_id = $1 AND ($2::INTEGER IS NULL OR w.dictionary_id = $2) ORDER BY w.id ` rows, err := a.DB.Query(query, userID, dictionaryID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() words := make([]Word, 0) for rows.Next() { var word Word var lastSuccess, lastFailure sql.NullString err := rows.Scan( &word.ID, &word.Name, &word.Translation, &word.Description, &word.Success, &word.Failure, &lastSuccess, &lastFailure, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if lastSuccess.Valid { word.LastSuccess = &lastSuccess.String } if lastFailure.Valid { word.LastFailure = &lastFailure.String } words = append(words, word) } setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(words) } func (a *App) addWordsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req WordsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding addWords request: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } log.Printf("addWords: user_id=%d, words_count=%d", userID, len(req.Words)) tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() // Create default dictionary for user if needed var defaultDictID int err = tx.QueryRow(` SELECT id FROM dictionaries WHERE user_id = $1 ORDER BY id LIMIT 1 `, userID).Scan(&defaultDictID) if err == sql.ErrNoRows { // Create default dictionary for user log.Printf("Creating default dictionary for user_id=%d", userID) err = tx.QueryRow(` INSERT INTO dictionaries (name, user_id) VALUES ('Все слова', $1) RETURNING id `, userID).Scan(&defaultDictID) if err != nil { log.Printf("Error creating default dictionary: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } log.Printf("Created default dictionary id=%d for user_id=%d", defaultDictID, userID) } else if err != nil { log.Printf("Error finding default dictionary: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } else { log.Printf("Using default dictionary id=%d for user_id=%d", defaultDictID, userID) } stmt, err := tx.Prepare(` INSERT INTO words (name, translation, description, dictionary_id, user_id) VALUES ($1, $2, $3, $4, $5) RETURNING id `) if err != nil { log.Printf("Error preparing insert statement: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() var addedCount int for i, wordReq := range req.Words { var id int dictionaryID := defaultDictID if wordReq.DictionaryID != nil { dictionaryID = *wordReq.DictionaryID // Проверяем, что словарь принадлежит пользователю var dictUserID int err := tx.QueryRow(` SELECT user_id FROM dictionaries WHERE id = $1 `, dictionaryID).Scan(&dictUserID) if err == sql.ErrNoRows { log.Printf("Dictionary %d not found for word %d", dictionaryID, i) sendErrorWithCORS(w, fmt.Sprintf("Dictionary %d not found", dictionaryID), http.StatusBadRequest) return } else if err != nil { log.Printf("Error checking dictionary ownership: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if dictUserID != userID { log.Printf("Dictionary %d belongs to user %d, but request from user %d", dictionaryID, dictUserID, userID) sendErrorWithCORS(w, fmt.Sprintf("Dictionary %d does not belong to user", dictionaryID), http.StatusForbidden) return } } err := stmt.QueryRow(wordReq.Name, wordReq.Translation, wordReq.Description, dictionaryID, userID).Scan(&id) if err != nil { log.Printf("Error inserting word %d (name='%s', dict_id=%d, user_id=%d): %v", i, wordReq.Name, dictionaryID, userID, err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } addedCount++ log.Printf("Successfully added word id=%d: name='%s', dict_id=%d", id, wordReq.Name, dictionaryID) } if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } log.Printf("Successfully added %d words for user_id=%d", addedCount, userID) setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": fmt.Sprintf("Added %d words", addedCount), "added": addedCount, }) } func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { log.Printf("getTestWordsHandler called: %s %s", r.Method, r.URL.Path) setCORSHeaders(w) if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Get config_id from query parameter (required) configIDStr := r.URL.Query().Get("config_id") if configIDStr == "" { sendErrorWithCORS(w, "config_id parameter is required", http.StatusBadRequest) return } configID, err := strconv.Atoi(configIDStr) if err != nil { sendErrorWithCORS(w, "invalid config_id parameter", http.StatusBadRequest) return } // Get words_count from config (verify ownership) var wordsCount int err = a.DB.QueryRow("SELECT words_count FROM configs WHERE id = $1 AND user_id = $2", configID, userID).Scan(&wordsCount) if err != nil { if err == sql.ErrNoRows { sendErrorWithCORS(w, "config not found", http.StatusNotFound) return } sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Get dictionary IDs for this config var dictionaryIDs []int dictQuery := ` SELECT dictionary_id FROM config_dictionaries WHERE config_id = $1 ` dictRows, err := a.DB.Query(dictQuery, configID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer dictRows.Close() for dictRows.Next() { var dictID int if err := dictRows.Scan(&dictID); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } dictionaryIDs = append(dictionaryIDs, dictID) } // If no dictionaries are selected for config, use all dictionaries (no filter) var dictFilter string var dictArgs []interface{} if len(dictionaryIDs) > 0 { placeholders := make([]string, len(dictionaryIDs)) for i := range dictionaryIDs { placeholders[i] = fmt.Sprintf("$%d", i+1) } dictFilter = fmt.Sprintf("w.dictionary_id IN (%s)", strings.Join(placeholders, ",")) for _, dictID := range dictionaryIDs { dictArgs = append(dictArgs, dictID) } } else { dictFilter = "1=1" // No filter } // Calculate group sizes (use ceiling to ensure we don't lose words due to rounding) group1Count := int(float64(wordsCount) * 0.3) // 30% group2Count := int(float64(wordsCount) * 0.4) // 40% // group3Count is calculated dynamically based on actual words collected from groups 1 and 2 // Base query parts baseSelect := ` w.id, w.name, w.translation, w.description, COALESCE(p.success, 0) as success, COALESCE(p.failure, 0) as failure, CASE WHEN p.last_success_at IS NOT NULL THEN p.last_success_at::text ELSE NULL END as last_success_at, CASE WHEN p.last_failure_at IS NOT NULL THEN p.last_failure_at::text ELSE NULL END as last_failure_at ` baseFrom := fmt.Sprintf(` FROM words w JOIN dictionaries d ON w.dictionary_id = d.id AND d.user_id = %d LEFT JOIN progress p ON w.id = p.word_id AND p.user_id = %d WHERE `, userID, userID) + dictFilter // Group 1: success <= 3, sorted by success ASC, then last_success_at ASC (NULL first) group1Query := ` SELECT ` + baseSelect + ` ` + baseFrom + ` AND COALESCE(p.success, 0) <= 3 ORDER BY COALESCE(p.success, 0) ASC, CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END, p.last_success_at ASC LIMIT $` + fmt.Sprintf("%d", len(dictArgs)+1) group1Args := append(dictArgs, group1Count*2) // Get more to ensure uniqueness group1Rows, err := a.DB.Query(group1Query, group1Args...) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer group1Rows.Close() group1Words := make([]Word, 0) group1WordIDs := make(map[int]bool) for group1Rows.Next() && len(group1Words) < group1Count { var word Word var lastSuccess, lastFailure sql.NullString err := group1Rows.Scan( &word.ID, &word.Name, &word.Translation, &word.Description, &word.Success, &word.Failure, &lastSuccess, &lastFailure, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if lastSuccess.Valid { word.LastSuccess = &lastSuccess.String } if lastFailure.Valid { word.LastFailure = &lastFailure.String } group1Words = append(group1Words, word) group1WordIDs[word.ID] = true } // Group 2: sorted by (failure + 1)/(success + 1) DESC, take top 40% // Exclude words already in group1 group2Exclude := "" group2Args := make([]interface{}, 0) group2Args = append(group2Args, dictArgs...) if len(group1WordIDs) > 0 { excludePlaceholders := make([]string, 0, len(group1WordIDs)) idx := len(dictArgs) + 1 for wordID := range group1WordIDs { excludePlaceholders = append(excludePlaceholders, fmt.Sprintf("$%d", idx)) group2Args = append(group2Args, wordID) idx++ } group2Exclude = " AND w.id NOT IN (" + strings.Join(excludePlaceholders, ",") + ")" } group2Query := ` SELECT ` + baseSelect + ` ` + baseFrom + ` ` + group2Exclude + ` ORDER BY (COALESCE(p.failure, 0) + 1.0) / (COALESCE(p.success, 0) + 1.0) DESC, CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END, p.last_success_at ASC LIMIT $` + fmt.Sprintf("%d", len(group2Args)+1) group2Args = append(group2Args, group2Count) group2Rows, err := a.DB.Query(group2Query, group2Args...) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer group2Rows.Close() group2Words := make([]Word, 0) group2WordIDs := make(map[int]bool) for group2Rows.Next() { var word Word var lastSuccess, lastFailure sql.NullString err := group2Rows.Scan( &word.ID, &word.Name, &word.Translation, &word.Description, &word.Success, &word.Failure, &lastSuccess, &lastFailure, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if lastSuccess.Valid { word.LastSuccess = &lastSuccess.String } if lastFailure.Valid { word.LastFailure = &lastFailure.String } group2Words = append(group2Words, word) group2WordIDs[word.ID] = true } // Group 3: All remaining words, sorted by last_success_at ASC (NULL first) // Exclude words already in group1 and group2 allExcludedIDs := make(map[int]bool) for id := range group1WordIDs { allExcludedIDs[id] = true } for id := range group2WordIDs { allExcludedIDs[id] = true } group3Exclude := "" group3Args := make([]interface{}, 0) group3Args = append(group3Args, dictArgs...) if len(allExcludedIDs) > 0 { excludePlaceholders := make([]string, 0, len(allExcludedIDs)) idx := len(dictArgs) + 1 for wordID := range allExcludedIDs { excludePlaceholders = append(excludePlaceholders, fmt.Sprintf("$%d", idx)) group3Args = append(group3Args, wordID) idx++ } group3Exclude = " AND w.id NOT IN (" + strings.Join(excludePlaceholders, ",") + ")" } // Calculate how many words we still need from group 3 wordsCollected := len(group1Words) + len(group2Words) group3Needed := wordsCount - wordsCollected log.Printf("Word selection: wordsCount=%d, group1=%d, group2=%d, collected=%d, group3Needed=%d", wordsCount, len(group1Words), len(group2Words), wordsCollected, group3Needed) group3Words := make([]Word, 0) if group3Needed > 0 { group3Query := ` SELECT ` + baseSelect + ` ` + baseFrom + ` ` + group3Exclude + ` ORDER BY CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END, p.last_success_at ASC LIMIT $` + fmt.Sprintf("%d", len(group3Args)+1) group3Args = append(group3Args, group3Needed) group3Rows, err := a.DB.Query(group3Query, group3Args...) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer group3Rows.Close() for group3Rows.Next() { var word Word var lastSuccess, lastFailure sql.NullString err := group3Rows.Scan( &word.ID, &word.Name, &word.Translation, &word.Description, &word.Success, &word.Failure, &lastSuccess, &lastFailure, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if lastSuccess.Valid { word.LastSuccess = &lastSuccess.String } if lastFailure.Valid { word.LastFailure = &lastFailure.String } group3Words = append(group3Words, word) } } // Combine all groups words := make([]Word, 0) words = append(words, group1Words...) words = append(words, group2Words...) words = append(words, group3Words...) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(words) } func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) { log.Printf("updateTestProgressHandler called: %s %s", r.Method, r.URL.Path) setCORSHeaders(w) if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req TestProgressRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding request: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } log.Printf("Received %d word updates, config_id: %v, user_id: %d", len(req.Words), req.ConfigID, userID) tx, err := a.DB.Begin() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() // Create unique constraint for (word_id, user_id) if not exists tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS progress_word_user_unique ON progress(word_id, user_id)") stmt, err := tx.Prepare(` INSERT INTO progress (word_id, user_id, success, failure, last_success_at, last_failure_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (word_id, user_id) DO UPDATE SET success = EXCLUDED.success, failure = EXCLUDED.failure, last_success_at = COALESCE(EXCLUDED.last_success_at, progress.last_success_at), last_failure_at = COALESCE(EXCLUDED.last_failure_at, progress.last_failure_at) `) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() for _, wordUpdate := range req.Words { // Convert pointers to values for logging lastSuccessStr := "nil" if wordUpdate.LastSuccessAt != nil { lastSuccessStr = *wordUpdate.LastSuccessAt } lastFailureStr := "nil" if wordUpdate.LastFailureAt != nil { lastFailureStr = *wordUpdate.LastFailureAt } log.Printf("Updating word %d: success=%d, failure=%d, last_success_at=%s, last_failure_at=%s", wordUpdate.ID, wordUpdate.Success, wordUpdate.Failure, lastSuccessStr, lastFailureStr) // Convert pointers to sql.NullString for proper NULL handling var lastSuccess, lastFailure interface{} if wordUpdate.LastSuccessAt != nil && *wordUpdate.LastSuccessAt != "" { lastSuccess = *wordUpdate.LastSuccessAt } else { lastSuccess = nil } if wordUpdate.LastFailureAt != nil && *wordUpdate.LastFailureAt != "" { lastFailure = *wordUpdate.LastFailureAt } else { lastFailure = nil } _, err := stmt.Exec( wordUpdate.ID, userID, wordUpdate.Success, wordUpdate.Failure, lastSuccess, lastFailure, ) if err != nil { log.Printf("Error executing update for word %d: %v", wordUpdate.ID, err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } } if err := tx.Commit(); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // If config_id is provided, send webhook with try_message if req.ConfigID != nil { configID := *req.ConfigID // Use mutex to prevent duplicate webhook sends a.webhookMutex.Lock() lastTime, exists := a.lastWebhookTime[configID] now := time.Now() // Only send webhook if it hasn't been sent in the last 5 seconds for this config shouldSend := !exists || now.Sub(lastTime) > 5*time.Second if shouldSend { a.lastWebhookTime[configID] = now } a.webhookMutex.Unlock() if !shouldSend { log.Printf("Webhook skipped for config_id %d (sent recently)", configID) } else { var tryMessage sql.NullString err := a.DB.QueryRow("SELECT try_message FROM configs WHERE id = $1", configID).Scan(&tryMessage) if err == nil && tryMessage.Valid && tryMessage.String != "" { // Process message directly (backend always runs together with frontend) _, err := a.processMessage(tryMessage.String, &userID) if err != nil { log.Printf("Error processing message: %v", err) // Remove from map on error so it can be retried a.webhookMutex.Lock() delete(a.lastWebhookTime, configID) a.webhookMutex.Unlock() } else { log.Printf("Message processed successfully for config_id %d", configID) } } else if err != nil && err != sql.ErrNoRows { log.Printf("Error fetching config: %v", err) } else if err == nil && (!tryMessage.Valid || tryMessage.String == "") { log.Printf("Webhook skipped for config_id %d (try_message is empty)", configID) } } } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Progress updated successfully", }) } func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } query := ` SELECT id, name, words_count, max_cards, try_message FROM configs WHERE user_id = $1 ORDER BY id ` rows, err := a.DB.Query(query, userID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() configs := make([]Config, 0) for rows.Next() { var config Config var maxCards sql.NullInt64 err := rows.Scan( &config.ID, &config.Name, &config.WordsCount, &maxCards, &config.TryMessage, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if maxCards.Valid { maxCardsVal := int(maxCards.Int64) config.MaxCards = &maxCardsVal } configs = append(configs, config) } setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(configs) } func (a *App) getDictionariesHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } query := ` SELECT d.id, d.name, COALESCE(COUNT(w.id), 0) as words_count FROM dictionaries d LEFT JOIN words w ON d.id = w.dictionary_id WHERE d.user_id = $1 GROUP BY d.id, d.name ORDER BY d.id ` rows, err := a.DB.Query(query, userID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() dictionaries := make([]Dictionary, 0) for rows.Next() { var dict Dictionary err := rows.Scan( &dict.ID, &dict.Name, &dict.WordsCount, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } dictionaries = append(dictionaries, dict) } setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(dictionaries) } func (a *App) addDictionaryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req DictionaryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { sendErrorWithCORS(w, "Имя словаря обязательно", http.StatusBadRequest) return } var id int err := a.DB.QueryRow(` INSERT INTO dictionaries (name, user_id) VALUES ($1, $2) RETURNING id `, req.Name, userID).Scan(&id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "id": id, "name": req.Name, }) } func (a *App) updateDictionaryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) dictionaryID := vars["id"] var req DictionaryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { sendErrorWithCORS(w, "Имя словаря обязательно", http.StatusBadRequest) return } result, err := a.DB.Exec(` UPDATE dictionaries SET name = $1 WHERE id = $2 AND user_id = $3 `, req.Name, dictionaryID, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, err := result.RowsAffected() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if rowsAffected == 0 { http.Error(w, "Dictionary not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Dictionary updated successfully", }) } func (a *App) deleteDictionaryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) dictionaryID := vars["id"] // Prevent deletion of default dictionary (id = 0) if dictionaryID == "0" { sendErrorWithCORS(w, "Cannot delete default dictionary", http.StatusBadRequest) return } // Verify ownership var ownerID int err := a.DB.QueryRow("SELECT user_id FROM dictionaries WHERE id = $1", dictionaryID).Scan(&ownerID) if err != nil || ownerID != userID { sendErrorWithCORS(w, "Dictionary not found", http.StatusNotFound) return } tx, err := a.DB.Begin() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() // Delete all words from this dictionary (progress will be deleted automatically due to CASCADE) _, err = tx.Exec(` DELETE FROM words WHERE dictionary_id = $1 `, dictionaryID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Delete all config-dictionary associations (will be deleted automatically due to CASCADE, but doing explicitly for clarity) _, err = tx.Exec(` DELETE FROM config_dictionaries WHERE dictionary_id = $1 `, dictionaryID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Delete the dictionary result, err := tx.Exec("DELETE FROM dictionaries WHERE id = $1", dictionaryID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, err := result.RowsAffected() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if rowsAffected == 0 { sendErrorWithCORS(w, "Dictionary not found", http.StatusNotFound) return } if err := tx.Commit(); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Dictionary deleted successfully. All words and configuration associations have been deleted.", }) } func (a *App) getConfigDictionariesHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.WriteHeader(http.StatusOK) return } vars := mux.Vars(r) configID := vars["id"] query := ` SELECT dictionary_id FROM config_dictionaries WHERE config_id = $1 ORDER BY dictionary_id ` rows, err := a.DB.Query(query, configID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() dictionaryIDs := make([]int, 0) for rows.Next() { var dictID int err := rows.Scan(&dictID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } dictionaryIDs = append(dictionaryIDs, dictID) } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "dictionary_ids": dictionaryIDs, }) } func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { log.Printf("getTestConfigsAndDictionariesHandler: Unauthorized request") sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } log.Printf("getTestConfigsAndDictionariesHandler called, user: %d", userID) // Get configs configsQuery := ` SELECT id, name, words_count, max_cards, try_message FROM configs WHERE user_id = $1 ORDER BY id ` configsRows, err := a.DB.Query(configsQuery, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer configsRows.Close() configs := make([]Config, 0) for configsRows.Next() { var config Config var maxCards sql.NullInt64 err := configsRows.Scan( &config.ID, &config.Name, &config.WordsCount, &maxCards, &config.TryMessage, ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if maxCards.Valid { maxCardsVal := int(maxCards.Int64) config.MaxCards = &maxCardsVal } configs = append(configs, config) } // Get dictionaries dictsQuery := ` SELECT d.id, d.name, COALESCE(COUNT(w.id), 0) as words_count FROM dictionaries d LEFT JOIN words w ON d.id = w.dictionary_id WHERE d.user_id = $1 GROUP BY d.id, d.name ORDER BY d.id ` dictsRows, err := a.DB.Query(dictsQuery, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer dictsRows.Close() dictionaries := make([]Dictionary, 0) for dictsRows.Next() { var dict Dictionary err := dictsRows.Scan( &dict.ID, &dict.Name, &dict.WordsCount, ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } dictionaries = append(dictionaries, dict) } response := TestConfigsAndDictionariesResponse{ Configs: configs, Dictionaries: dictionaries, } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(response) } func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req ConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest) return } if req.WordsCount <= 0 { sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest) return } tx, err := a.DB.Begin() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() var id int err = tx.QueryRow(` INSERT INTO configs (name, words_count, max_cards, try_message, user_id) VALUES ($1, $2, $3, $4, $5) RETURNING id `, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, userID).Scan(&id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Insert dictionary associations if provided if len(req.DictionaryIDs) > 0 { stmt, err := tx.Prepare(` INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2) `) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() for _, dictID := range req.DictionaryIDs { _, err := stmt.Exec(id, dictID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } } if err := tx.Commit(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Config created successfully", "id": id, }) } func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) configID := vars["id"] // Verify ownership var ownerID int err := a.DB.QueryRow("SELECT user_id FROM configs WHERE id = $1", configID).Scan(&ownerID) if err != nil || ownerID != userID { sendErrorWithCORS(w, "Config not found", http.StatusNotFound) return } var req ConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest) return } if req.WordsCount <= 0 { sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest) return } tx, err := a.DB.Begin() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() result, err := tx.Exec(` UPDATE configs SET name = $1, words_count = $2, max_cards = $3, try_message = $4 WHERE id = $5 `, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, configID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, err := result.RowsAffected() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if rowsAffected == 0 { http.Error(w, "Config not found", http.StatusNotFound) return } // Delete existing dictionary associations _, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", configID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Insert new dictionary associations if provided if len(req.DictionaryIDs) > 0 { stmt, err := tx.Prepare(` INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2) `) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() for _, dictID := range req.DictionaryIDs { _, err := stmt.Exec(configID, dictID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } } if err := tx.Commit(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Config updated successfully", }) } func (a *App) deleteConfigHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) configID := vars["id"] result, err := a.DB.Exec("DELETE FROM configs WHERE id = $1 AND user_id = $2", configID, userID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, err := result.RowsAffected() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if rowsAffected == 0 { sendErrorWithCORS(w, "Config not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Config deleted successfully", }) } func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } log.Printf("getWeeklyStatsHandler called from %s, path: %s, user: %d", r.RemoteAddr, r.URL.Path, userID) // Опционально обновляем materialized view перед запросом // Это можно сделать через query parameter ?refresh=true if r.URL.Query().Get("refresh") == "true" { _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") if err != nil { log.Printf("Warning: Failed to refresh materialized view: %v", err) // Продолжаем выполнение даже если обновление не удалось } } query := ` SELECT p.name AS project_name, -- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, wg.max_goal_score, wg.priority AS priority FROM projects p LEFT JOIN weekly_goals wg ON wg.project_id = p.id AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER LEFT JOIN weekly_report_mv wr ON p.id = wr.project_id AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week WHERE p.deleted = FALSE AND p.user_id = $1 ORDER BY total_score DESC ` rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying weekly stats: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() projects := make([]WeeklyProjectStats, 0) // Группы для расчета среднего по priority groups := make(map[int][]float64) for rows.Next() { var project WeeklyProjectStats var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &project.ProjectName, &project.TotalScore, &minGoalScore, &maxGoalScore, &priority, ) if err != nil { log.Printf("Error scanning weekly stats row: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if minGoalScore.Valid { project.MinGoalScore = minGoalScore.Float64 } else { project.MinGoalScore = 0 } if maxGoalScore.Valid { maxGoalVal := maxGoalScore.Float64 project.MaxGoalScore = &maxGoalVal } var priorityVal int if priority.Valid { priorityVal = int(priority.Int64) project.Priority = &priorityVal } // Расчет calculated_score по формуле из n8n totalScore := project.TotalScore minGoalScoreVal := project.MinGoalScore var maxGoalScoreVal float64 if project.MaxGoalScore != nil { maxGoalScoreVal = *project.MaxGoalScore } // Параметры бонуса в зависимости от priority var extraBonusLimit float64 = 20 if priorityVal == 1 { extraBonusLimit = 50 } else if priorityVal == 2 { extraBonusLimit = 35 } // Расчет базового прогресса var baseProgress float64 if minGoalScoreVal > 0 { baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 } // Расчет экстра прогресса var extraProgress float64 denominator := maxGoalScoreVal - minGoalScoreVal if denominator > 0 && totalScore > minGoalScoreVal { excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal extraProgress = (excess / denominator) * extraBonusLimit } resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета // Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения if minGoalScoreVal > 0 { if _, exists := groups[priorityVal]; !exists { groups[priorityVal] = make([]float64, 0) } groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) } projects = append(projects, project) } // Вычисляем общий процент выполнения total := calculateOverallProgress(groups) response := WeeklyStatsResponse{ Total: total, Projects: projects, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } func (a *App) initDB() error { createDictionariesTable := ` CREATE TABLE IF NOT EXISTS dictionaries ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL ) ` createWordsTable := ` CREATE TABLE IF NOT EXISTS words ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, translation TEXT NOT NULL, description TEXT ) ` createProgressTable := ` CREATE TABLE IF NOT EXISTS progress ( id SERIAL PRIMARY KEY, word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE, success INTEGER DEFAULT 0, failure INTEGER DEFAULT 0, last_success_at TIMESTAMP, last_failure_at TIMESTAMP, UNIQUE(word_id) ) ` createConfigsTable := ` CREATE TABLE IF NOT EXISTS configs ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, words_count INTEGER NOT NULL, max_cards INTEGER, try_message TEXT ) ` createConfigDictionariesTable := ` CREATE TABLE IF NOT EXISTS config_dictionaries ( config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE, dictionary_id INTEGER NOT NULL REFERENCES dictionaries(id) ON DELETE CASCADE, PRIMARY KEY (config_id, dictionary_id) ) ` createConfigDictionariesIndexes := []string{ `CREATE INDEX IF NOT EXISTS idx_config_dictionaries_config_id ON config_dictionaries(config_id)`, `CREATE INDEX IF NOT EXISTS idx_config_dictionaries_dictionary_id ON config_dictionaries(dictionary_id)`, } // Alter existing table to make try_message nullable if it's not already alterConfigsTable := ` ALTER TABLE configs ALTER COLUMN try_message DROP NOT NULL ` // Alter existing table to add max_cards column if it doesn't exist alterConfigsTableMaxCards := ` ALTER TABLE configs ADD COLUMN IF NOT EXISTS max_cards INTEGER ` // Create dictionaries table first if _, err := a.DB.Exec(createDictionariesTable); err != nil { return err } // Insert default dictionary "Все слова" with id = 0 // PostgreSQL SERIAL starts from 1, so we need to set sequence to -1 first insertDefaultDictionary := ` DO $$ BEGIN -- Set sequence to -1 so next value will be 0 PERFORM setval('dictionaries_id_seq', -1, false); -- Insert the default dictionary with id = 0 INSERT INTO dictionaries (id, name) VALUES (0, 'Все слова') ON CONFLICT (id) DO NOTHING; -- Set the sequence to start from 1 (so next auto-increment will be 1) PERFORM setval('dictionaries_id_seq', 1, false); EXCEPTION WHEN others THEN -- If sequence doesn't exist or other error, try without sequence manipulation INSERT INTO dictionaries (id, name) VALUES (0, 'Все слова') ON CONFLICT (id) DO NOTHING; END $$; ` if _, err := a.DB.Exec(insertDefaultDictionary); err != nil { log.Printf("Warning: Failed to insert default dictionary: %v. Trying alternative method.", err) // Alternative: try to insert without sequence manipulation _, err2 := a.DB.Exec(`INSERT INTO dictionaries (id, name) VALUES (0, 'Все слова') ON CONFLICT (id) DO NOTHING`) if err2 != nil { log.Printf("Warning: Alternative insert also failed: %v", err2) } } if _, err := a.DB.Exec(createWordsTable); err != nil { return err } // Add dictionary_id column to words if it doesn't exist // First check if column exists, if not add it checkColumnExists := ` SELECT COUNT(*) FROM information_schema.columns WHERE table_name='words' AND column_name='dictionary_id' ` var columnExists int err := a.DB.QueryRow(checkColumnExists).Scan(&columnExists) if err == nil && columnExists == 0 { // Column doesn't exist, add it alterWordsTable := ` ALTER TABLE words ADD COLUMN dictionary_id INTEGER DEFAULT 0 ` if _, err := a.DB.Exec(alterWordsTable); err != nil { log.Printf("Warning: Failed to add dictionary_id column: %v", err) } else { // Add foreign key constraint addForeignKey := ` ALTER TABLE words ADD CONSTRAINT words_dictionary_id_fkey FOREIGN KEY (dictionary_id) REFERENCES dictionaries(id) ` a.DB.Exec(addForeignKey) } } // Update existing words to have dictionary_id = 0 updateWordsDictionaryID := ` UPDATE words SET dictionary_id = 0 WHERE dictionary_id IS NULL ` a.DB.Exec(updateWordsDictionaryID) // Make dictionary_id NOT NULL after setting default values (if column exists) if columnExists > 0 || err == nil { alterWordsTableNotNull := ` DO $$ BEGIN ALTER TABLE words ALTER COLUMN dictionary_id SET NOT NULL, ALTER COLUMN dictionary_id SET DEFAULT 0; EXCEPTION WHEN others THEN -- Ignore if already NOT NULL NULL; END $$; ` a.DB.Exec(alterWordsTableNotNull) } // Create index on dictionary_id createDictionaryIndex := ` CREATE INDEX IF NOT EXISTS idx_words_dictionary_id ON words(dictionary_id) ` a.DB.Exec(createDictionaryIndex) // Remove unique constraint on words.name if it exists removeUniqueConstraint := ` ALTER TABLE words DROP CONSTRAINT IF EXISTS words_name_key; ALTER TABLE words DROP CONSTRAINT IF EXISTS words_name_unique; ` a.DB.Exec(removeUniqueConstraint) if _, err := a.DB.Exec(createProgressTable); err != nil { return err } if _, err := a.DB.Exec(createConfigsTable); err != nil { return err } // Try to alter existing table to make try_message nullable // Ignore error if column is already nullable or table doesn't exist a.DB.Exec(alterConfigsTable) // Try to alter existing table to add max_cards column // Ignore error if column already exists a.DB.Exec(alterConfigsTableMaxCards) // Create config_dictionaries table if _, err := a.DB.Exec(createConfigDictionariesTable); err != nil { return err } // Create indexes for config_dictionaries for _, indexSQL := range createConfigDictionariesIndexes { if _, err := a.DB.Exec(indexSQL); err != nil { log.Printf("Warning: Failed to create config_dictionaries index: %v", err) } } return nil } func (a *App) initAuthDB() error { // Create users table createUsersTable := ` CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, name VARCHAR(255), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, is_active BOOLEAN DEFAULT TRUE, last_login_at TIMESTAMP WITH TIME ZONE ) ` if _, err := a.DB.Exec(createUsersTable); err != nil { return err } // Create index on email a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)") // Create refresh_tokens table createRefreshTokensTable := ` CREATE TABLE IF NOT EXISTS refresh_tokens ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash VARCHAR(255) NOT NULL, expires_at TIMESTAMP WITH TIME ZONE NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ) ` if _, err := a.DB.Exec(createRefreshTokensTable); err != nil { return err } // Create indexes for refresh_tokens a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id)") a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash)") // Add user_id column to all tables if not exists tables := []string{"projects", "entries", "nodes", "dictionaries", "words", "progress", "configs", "telegram_integrations", "weekly_goals", "tasks"} for _, table := range tables { alterSQL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE", table) if _, err := a.DB.Exec(alterSQL); err != nil { log.Printf("Warning: Failed to add user_id to %s: %v", table, err) } indexSQL := fmt.Sprintf("CREATE INDEX IF NOT EXISTS idx_%s_user_id ON %s(user_id)", table, table) a.DB.Exec(indexSQL) } // Drop old unique constraint on projects.name (now unique per user, not globally) a.DB.Exec("ALTER TABLE projects DROP CONSTRAINT IF EXISTS unique_project_name") // Drop old unique constraint on progress.word_id (now unique per user) a.DB.Exec("ALTER TABLE progress DROP CONSTRAINT IF EXISTS progress_word_id_key") // Create new unique constraint per user for progress a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id)") // Add webhook_token to telegram_integrations for URL-based user identification (legacy, will be removed in migration 012) a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(255)") a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_webhook_token ON telegram_integrations(webhook_token) WHERE webhook_token IS NOT NULL") // Apply migration 012: Refactor telegram_integrations for single shared bot if err := a.applyMigration012(); err != nil { log.Printf("Warning: Failed to apply migration 012: %v", err) // Не возвращаем ошибку, чтобы приложение могло запуститься } // Apply migration 013: Refactor todoist_integrations for single Todoist app if err := a.applyMigration013(); err != nil { log.Printf("Warning: Failed to apply migration 013: %v", err) // Не возвращаем ошибку, чтобы приложение могло запуститься } // Clean up expired refresh tokens (only those with expiration date set) a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()") return nil } // applyMigration012 применяет миграцию 012_refactor_telegram_single_bot.sql func (a *App) applyMigration012() error { log.Printf("Applying migration 012: Refactor telegram_integrations for single shared bot") // 1. Создаем таблицу todoist_integrations createTodoistIntegrationsTable := ` CREATE TABLE IF NOT EXISTS todoist_integrations ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, webhook_token VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id) ) ` if _, err := a.DB.Exec(createTodoistIntegrationsTable); err != nil { return fmt.Errorf("failed to create todoist_integrations table: %w", err) } // Создаем индексы для todoist_integrations a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_webhook_token ON todoist_integrations(webhook_token)") a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_todoist_integrations_user_id ON todoist_integrations(user_id)") // 2. Мигрируем webhook_token из telegram_integrations в todoist_integrations migrateWebhookTokens := ` INSERT INTO todoist_integrations (user_id, webhook_token, created_at, updated_at) SELECT user_id, webhook_token, COALESCE(created_at, CURRENT_TIMESTAMP), CURRENT_TIMESTAMP FROM telegram_integrations WHERE webhook_token IS NOT NULL AND webhook_token != '' AND user_id IS NOT NULL ON CONFLICT (user_id) DO NOTHING ` if _, err := a.DB.Exec(migrateWebhookTokens); err != nil { log.Printf("Warning: Failed to migrate webhook_token to todoist_integrations: %v", err) // Продолжаем выполнение, так как это может быть уже выполнено } // 3. Удаляем bot_token (будет в .env) a.DB.Exec("ALTER TABLE telegram_integrations DROP COLUMN IF EXISTS bot_token") // 4. Удаляем webhook_token (перенесли в todoist_integrations) a.DB.Exec("ALTER TABLE telegram_integrations DROP COLUMN IF EXISTS webhook_token") // 5. Добавляем telegram_user_id a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS telegram_user_id BIGINT") // 6. Добавляем start_token a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS start_token VARCHAR(255)") // 7. Добавляем timestamps если их нет a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP") a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP") // 8. Создаем индексы a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_start_token ON telegram_integrations(start_token) WHERE start_token IS NOT NULL") a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_telegram_user_id ON telegram_integrations(telegram_user_id) WHERE telegram_user_id IS NOT NULL") // Уникальность user_id a.DB.Exec("DROP INDEX IF EXISTS idx_telegram_integrations_user_id") a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_user_id_unique ON telegram_integrations(user_id) WHERE user_id IS NOT NULL") // Индекс для поиска по chat_id a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_telegram_integrations_chat_id ON telegram_integrations(chat_id) WHERE chat_id IS NOT NULL") // Удаляем старый индекс webhook_token a.DB.Exec("DROP INDEX IF EXISTS idx_telegram_integrations_webhook_token") // 9. Очищаем данные Telegram для переподключения (только если еще не очищены) // Проверяем, есть ли записи с заполненными chat_id или telegram_user_id var count int err := a.DB.QueryRow(` SELECT COUNT(*) FROM telegram_integrations WHERE (chat_id IS NOT NULL OR telegram_user_id IS NOT NULL) AND (start_token IS NULL OR start_token = '') `).Scan(&count) // Если есть старые данные без start_token, очищаем их для переподключения if err == nil && count > 0 { log.Printf("Clearing old Telegram integration data for %d users (they will need to reconnect)", count) a.DB.Exec(` UPDATE telegram_integrations SET chat_id = NULL, telegram_user_id = NULL, start_token = NULL, updated_at = CURRENT_TIMESTAMP WHERE (chat_id IS NOT NULL OR telegram_user_id IS NOT NULL) AND (start_token IS NULL OR start_token = '') `) } log.Printf("Migration 012 applied successfully") return nil } // applyMigration013 применяет миграцию 013_refactor_todoist_single_app.sql func (a *App) applyMigration013() error { log.Printf("Applying migration 013: Refactor todoist_integrations for single Todoist app") // 1. Добавляем новые поля a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS todoist_user_id BIGINT") a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS todoist_email VARCHAR(255)") a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS access_token TEXT") // 2. Удаляем webhook_token a.DB.Exec("ALTER TABLE todoist_integrations DROP COLUMN IF EXISTS webhook_token") // 3. Удаляем старый индекс a.DB.Exec("DROP INDEX IF EXISTS idx_todoist_integrations_webhook_token") // 4. Создаем новые индексы a.DB.Exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_user_id ON todoist_integrations(todoist_user_id) WHERE todoist_user_id IS NOT NULL `) a.DB.Exec(` CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email ON todoist_integrations(todoist_email) WHERE todoist_email IS NOT NULL `) log.Printf("Migration 013 applied successfully") return nil } func (a *App) initPlayLifeDB() error { // Создаем таблицу projects createProjectsTable := ` CREATE TABLE IF NOT EXISTS projects ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, priority SMALLINT, CONSTRAINT unique_project_name UNIQUE (name) ) ` // Создаем таблицу entries createEntriesTable := ` CREATE TABLE IF NOT EXISTS entries ( id SERIAL PRIMARY KEY, text TEXT NOT NULL, created_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ) ` // Создаем таблицу nodes createNodesTable := ` CREATE TABLE IF NOT EXISTS nodes ( id SERIAL PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE, score NUMERIC(8,4) ) ` // Создаем индексы для nodes createNodesIndexes := []string{ `CREATE INDEX IF NOT EXISTS idx_nodes_project_id ON nodes(project_id)`, `CREATE INDEX IF NOT EXISTS idx_nodes_entry_id ON nodes(entry_id)`, } // Создаем таблицу weekly_goals createWeeklyGoalsTable := ` CREATE TABLE IF NOT EXISTS weekly_goals ( id SERIAL PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, goal_year INTEGER NOT NULL, goal_week INTEGER NOT NULL, min_goal_score NUMERIC(10,4) NOT NULL DEFAULT 0, max_goal_score NUMERIC(10,4), actual_score NUMERIC(10,4) DEFAULT 0, priority SMALLINT, CONSTRAINT weekly_goals_project_id_goal_year_goal_week_key UNIQUE (project_id, goal_year, goal_week) ) ` // Создаем индекс для weekly_goals createWeeklyGoalsIndex := ` CREATE INDEX IF NOT EXISTS idx_weekly_goals_project_id ON weekly_goals(project_id) ` // Выполняем создание таблиц if _, err := a.DB.Exec(createProjectsTable); err != nil { return fmt.Errorf("failed to create projects table: %w", err) } // Добавляем колонку deleted, если её нет (для существующих баз) alterProjectsTable := ` ALTER TABLE projects ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE ` if _, err := a.DB.Exec(alterProjectsTable); err != nil { log.Printf("Warning: Failed to add deleted column to projects table: %v", err) } // Создаем индекс на deleted createProjectsDeletedIndex := ` CREATE INDEX IF NOT EXISTS idx_projects_deleted ON projects(deleted) ` if _, err := a.DB.Exec(createProjectsDeletedIndex); err != nil { log.Printf("Warning: Failed to create projects deleted index: %v", err) } if _, err := a.DB.Exec(createEntriesTable); err != nil { return fmt.Errorf("failed to create entries table: %w", err) } if _, err := a.DB.Exec(createNodesTable); err != nil { return fmt.Errorf("failed to create nodes table: %w", err) } for _, indexSQL := range createNodesIndexes { if _, err := a.DB.Exec(indexSQL); err != nil { log.Printf("Warning: Failed to create index: %v", err) } } if _, err := a.DB.Exec(createWeeklyGoalsTable); err != nil { return fmt.Errorf("failed to create weekly_goals table: %w", err) } if _, err := a.DB.Exec(createWeeklyGoalsIndex); err != nil { log.Printf("Warning: Failed to create weekly_goals index: %v", err) } // Создаем materialized view (может потребоваться удаление старого, если он существует) dropMaterializedView := `DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv` a.DB.Exec(dropMaterializedView) // Игнорируем ошибку, если view не существует createMaterializedView := ` CREATE MATERIALIZED VIEW weekly_report_mv AS SELECT p.id AS project_id, agg.report_year, agg.report_week, COALESCE(agg.total_score, 0.0000) AS total_score FROM projects p LEFT JOIN ( SELECT n.project_id, EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year, EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week, SUM(n.score) AS total_score FROM nodes n JOIN entries e ON n.entry_id = e.id GROUP BY 1, 2, 3 ) agg ON p.id = agg.project_id WHERE p.deleted = FALSE ORDER BY p.id, agg.report_year, agg.report_week ` if _, err := a.DB.Exec(createMaterializedView); err != nil { return fmt.Errorf("failed to create weekly_report_mv: %w", err) } // Создаем индекс для materialized view createMVIndex := ` CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week ON weekly_report_mv(project_id, report_year, report_week) ` if _, err := a.DB.Exec(createMVIndex); err != nil { log.Printf("Warning: Failed to create materialized view index: %v", err) } // Создаем таблицу telegram_integrations createTelegramIntegrationsTable := ` CREATE TABLE IF NOT EXISTS telegram_integrations ( id SERIAL PRIMARY KEY, chat_id VARCHAR(255), bot_token VARCHAR(255) ) ` if _, err := a.DB.Exec(createTelegramIntegrationsTable); err != nil { return fmt.Errorf("failed to create telegram_integrations table: %w", err) } // Создаем таблицу tasks createTasksTable := ` CREATE TABLE IF NOT EXISTS tasks ( id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, name VARCHAR(255) NOT NULL, completed INTEGER DEFAULT 0, last_completed_at TIMESTAMP WITH TIME ZONE, parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, reward_message TEXT, progression_base NUMERIC(10,4), deleted BOOLEAN DEFAULT FALSE ) ` if _, err := a.DB.Exec(createTasksTable); err != nil { return fmt.Errorf("failed to create tasks table: %w", err) } // Создаем индексы для tasks createTasksIndexes := []string{ `CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id)`, `CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id)`, `CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted)`, `CREATE INDEX IF NOT EXISTS idx_tasks_last_completed_at ON tasks(last_completed_at)`, } for _, indexSQL := range createTasksIndexes { if _, err := a.DB.Exec(indexSQL); err != nil { log.Printf("Warning: Failed to create tasks index: %v", err) } } // Apply migration 016: Add repetition_period to tasks if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS repetition_period INTERVAL"); err != nil { log.Printf("Warning: Failed to apply migration 016 (add repetition_period): %v", err) } // Apply migration 017: Add next_show_at to tasks if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE"); err != nil { log.Printf("Warning: Failed to apply migration 017 (add next_show_at): %v", err) } // Apply migration 018: Add repetition_date to tasks if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS repetition_date TEXT"); err != nil { log.Printf("Warning: Failed to apply migration 018 (add repetition_date): %v", err) } // Создаем таблицу reward_configs createRewardConfigsTable := ` CREATE TABLE IF NOT EXISTS reward_configs ( id SERIAL PRIMARY KEY, position INTEGER NOT NULL, task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE, value NUMERIC(10,4) NOT NULL, use_progression BOOLEAN DEFAULT FALSE ) ` if _, err := a.DB.Exec(createRewardConfigsTable); err != nil { return fmt.Errorf("failed to create reward_configs table: %w", err) } // Создаем индексы для reward_configs createRewardConfigsIndexes := []string{ `CREATE INDEX IF NOT EXISTS idx_reward_configs_task_id ON reward_configs(task_id)`, `CREATE INDEX IF NOT EXISTS idx_reward_configs_project_id ON reward_configs(project_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_reward_configs_task_position ON reward_configs(task_id, position)`, } for _, indexSQL := range createRewardConfigsIndexes { if _, err := a.DB.Exec(indexSQL); err != nil { log.Printf("Warning: Failed to create reward_configs index: %v", err) } } return nil } // startWeeklyGoalsScheduler запускает планировщик для автоматической фиксации целей на неделю // каждый понедельник в 6:00 утра в указанном часовом поясе func (a *App) startWeeklyGoalsScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") log.Printf("Loading timezone for weekly goals scheduler: '%s'", timezoneStr) // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'") loc = time.UTC timezoneStr = "UTC" } else { log.Printf("Weekly goals scheduler timezone set to: %s", timezoneStr) } // Логируем текущее время в указанном часовом поясе для проверки now := time.Now().In(loc) log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) log.Printf("Next weekly goals setup will be on Monday at: 06:00 %s (cron: '0 6 * * 1')", timezoneStr) // Создаем планировщик с указанным часовым поясом c := cron.New(cron.WithLocation(loc)) // Добавляем задачу: каждый понедельник в 6:00 утра // Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1) _, err = c.AddFunc("0 6 * * 1", func() { now := time.Now().In(loc) log.Printf("Scheduled task: Setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) if err := a.setupWeeklyGoals(); err != nil { log.Printf("Error in scheduled weekly goals setup: %v", err) } }) if err != nil { log.Printf("Error adding cron job for weekly goals: %v", err) return } // Запускаем планировщик c.Start() log.Printf("Weekly goals scheduler started: every Monday at 6:00 AM %s", timezoneStr) // Планировщик будет работать в фоновом режиме } // getWeeklyStatsData получает данные о проектах и их целях (без HTTP обработки) func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { // Обновляем materialized view перед запросом _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") if err != nil { log.Printf("Warning: Failed to refresh materialized view: %v", err) // Продолжаем выполнение даже если обновление не удалось } query := ` SELECT p.name AS project_name, -- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, wg.max_goal_score, wg.priority AS priority FROM projects p LEFT JOIN weekly_goals wg ON wg.project_id = p.id AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER LEFT JOIN weekly_report_mv wr ON p.id = wr.project_id AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week WHERE p.deleted = FALSE ORDER BY total_score DESC ` rows, err := a.DB.Query(query) if err != nil { log.Printf("Error querying weekly stats: %v", err) return nil, fmt.Errorf("error querying weekly stats: %w", err) } defer rows.Close() projects := make([]WeeklyProjectStats, 0) // Группы для расчета среднего по priority groups := make(map[int][]float64) for rows.Next() { var project WeeklyProjectStats var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &project.ProjectName, &project.TotalScore, &minGoalScore, &maxGoalScore, &priority, ) if err != nil { log.Printf("Error scanning weekly stats row: %v", err) return nil, fmt.Errorf("error scanning weekly stats row: %w", err) } if minGoalScore.Valid { project.MinGoalScore = minGoalScore.Float64 } else { project.MinGoalScore = 0 } if maxGoalScore.Valid { maxGoalVal := maxGoalScore.Float64 project.MaxGoalScore = &maxGoalVal } var priorityVal int if priority.Valid { priorityVal = int(priority.Int64) project.Priority = &priorityVal } // Расчет calculated_score по формуле из n8n totalScore := project.TotalScore minGoalScoreVal := project.MinGoalScore var maxGoalScoreVal float64 if project.MaxGoalScore != nil { maxGoalScoreVal = *project.MaxGoalScore } // Параметры бонуса в зависимости от priority var extraBonusLimit float64 = 20 if priorityVal == 1 { extraBonusLimit = 50 } else if priorityVal == 2 { extraBonusLimit = 35 } // Расчет базового прогресса var baseProgress float64 if minGoalScoreVal > 0 { baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 } // Расчет экстра прогресса var extraProgress float64 denominator := maxGoalScoreVal - minGoalScoreVal if denominator > 0 && totalScore > minGoalScoreVal { excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal extraProgress = (excess / denominator) * extraBonusLimit } resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета // Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения if minGoalScoreVal > 0 { if _, exists := groups[priorityVal]; !exists { groups[priorityVal] = make([]float64, 0) } groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) } projects = append(projects, project) } // Вычисляем общий процент выполнения total := calculateOverallProgress(groups) response := WeeklyStatsResponse{ Total: total, Projects: projects, } return &response, nil } // getWeeklyStatsDataForUser получает данные о проектах для конкретного пользователя func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error) { // Обновляем materialized view перед запросом _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") if err != nil { log.Printf("Warning: Failed to refresh materialized view: %v", err) } query := ` SELECT p.name AS project_name, COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, wg.max_goal_score, wg.priority AS priority FROM projects p LEFT JOIN weekly_goals wg ON wg.project_id = p.id AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER LEFT JOIN weekly_report_mv wr ON p.id = wr.project_id AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week WHERE p.deleted = FALSE AND p.user_id = $1 ORDER BY total_score DESC ` rows, err := a.DB.Query(query, userID) if err != nil { return nil, fmt.Errorf("error querying weekly stats: %w", err) } defer rows.Close() projects := make([]WeeklyProjectStats, 0) groups := make(map[int][]float64) for rows.Next() { var project WeeklyProjectStats var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &project.ProjectName, &project.TotalScore, &minGoalScore, &maxGoalScore, &priority, ) if err != nil { return nil, fmt.Errorf("error scanning weekly stats row: %w", err) } if minGoalScore.Valid { project.MinGoalScore = minGoalScore.Float64 } else { project.MinGoalScore = 0 } if maxGoalScore.Valid { maxGoalVal := maxGoalScore.Float64 project.MaxGoalScore = &maxGoalVal } var priorityVal int if priority.Valid { priorityVal = int(priority.Int64) project.Priority = &priorityVal } // Расчет calculated_score totalScore := project.TotalScore minGoalScoreVal := project.MinGoalScore var maxGoalScoreVal float64 if project.MaxGoalScore != nil { maxGoalScoreVal = *project.MaxGoalScore } // Параметры бонуса в зависимости от priority var extraBonusLimit float64 = 20 if priorityVal == 1 { extraBonusLimit = 50 } else if priorityVal == 2 { extraBonusLimit = 35 } // Расчет базового прогресса var baseProgress float64 if minGoalScoreVal > 0 { baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 } // Расчет экстра прогресса var extraProgress float64 denominator := maxGoalScoreVal - minGoalScoreVal if denominator > 0 && totalScore > minGoalScoreVal { excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal extraProgress = (excess / denominator) * extraBonusLimit } resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) projects = append(projects, project) // Группировка для итогового расчета // Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения if minGoalScoreVal > 0 { if _, exists := groups[priorityVal]; !exists { groups[priorityVal] = make([]float64, 0) } groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) } } // Вычисляем общий процент выполнения total := calculateOverallProgress(groups) response := WeeklyStatsResponse{ Total: total, Projects: projects, } return &response, nil } // formatDailyReport форматирует данные проектов в сообщение для Telegram // Формат аналогичен JS коду из n8n func (a *App) formatDailyReport(data *WeeklyStatsResponse) string { if data == nil || len(data.Projects) == 0 { return "" } // Заголовок сообщения markdownMessage := "*📈 Отчет по Score и Целям за текущую неделю:*\n\n" // Простой вывод списка проектов for _, item := range data.Projects { projectName := item.ProjectName if projectName == "" { projectName = "Без названия" } actualScore := item.TotalScore minGoal := item.MinGoalScore var maxGoal float64 hasMaxGoal := false if item.MaxGoalScore != nil { maxGoal = *item.MaxGoalScore hasMaxGoal = true } // Форматирование Score (+/-) scoreFormatted := "" if actualScore >= 0 { scoreFormatted = fmt.Sprintf("+%.2f", actualScore) } else { scoreFormatted = fmt.Sprintf("%.2f", actualScore) } // Форматирование текста целей // Проверяем, что minGoal валиден (не NaN, как в JS коде: !isNaN(minGoal)) goalText := "" if !math.IsNaN(minGoal) { if hasMaxGoal && !math.IsNaN(maxGoal) { goalText = fmt.Sprintf(" (Цель: %.1f–%.1f)", minGoal, maxGoal) } else { goalText = fmt.Sprintf(" (Цель: мин. %.1f)", minGoal) } } // Собираем строку: Проект: +Score (Цели) markdownMessage += fmt.Sprintf("*%s*: %s%s\n", projectName, scoreFormatted, goalText) } // Выводим итоговый total из корня JSON if data.Total != nil { markdownMessage += "\n---\n" markdownMessage += fmt.Sprintf("*Общее выполнение целей*: %.1f%%", *data.Total) } return markdownMessage } // sendDailyReport отправляет персональные ежедневные отчеты всем пользователям func (a *App) sendDailyReport() error { log.Printf("Scheduled task: Sending daily reports") userIDs, err := a.getAllUsersWithTelegram() if err != nil { return fmt.Errorf("error getting users: %w", err) } if len(userIDs) == 0 { log.Printf("No users with Telegram connected, skipping daily report") return nil } for _, userID := range userIDs { data, err := a.getWeeklyStatsDataForUser(userID) if err != nil { log.Printf("Error getting data for user %d: %v", userID, err) continue } message := a.formatDailyReport(data) if message == "" { continue } if err := a.sendTelegramMessageToUser(userID, message); err != nil { log.Printf("Error sending daily report to user %d: %v", userID, err) } else { log.Printf("Daily report sent to user %d", userID) } } return nil } // startDailyReportScheduler запускает планировщик для ежедневного отчета // каждый день в 23:59 в указанном часовом поясе func (a *App) startDailyReportScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") log.Printf("Loading timezone for daily report scheduler: '%s'", timezoneStr) // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'") loc = time.UTC timezoneStr = "UTC" } else { log.Printf("Daily report scheduler timezone set to: %s", timezoneStr) } // Логируем текущее время в указанном часовом поясе для проверки now := time.Now().In(loc) log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) log.Printf("Next daily report will be sent at: 23:59 %s (cron: '59 23 * * *')", timezoneStr) // Создаем планировщик с указанным часовым поясом c := cron.New(cron.WithLocation(loc)) // Добавляем задачу: каждый день в 23:59 // Cron выражение: "59 23 * * *" означает: минута=59, час=23, любой день месяца, любой месяц, любой день недели _, err = c.AddFunc("59 23 * * *", func() { now := time.Now().In(loc) log.Printf("Scheduled task: Sending daily report (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) if err := a.sendDailyReport(); err != nil { log.Printf("Error in scheduled daily report: %v", err) } }) if err != nil { log.Printf("Error adding cron job for daily report: %v", err) return } // Запускаем планировщик c.Start() log.Printf("Daily report scheduler started: every day at 23:59 %s", timezoneStr) // Планировщик будет работать в фоновом режиме } // readVersion читает версию из файла VERSION func readVersion() string { // Пробуем разные пути к файлу VERSION paths := []string{ "/app/VERSION", // В Docker контейнере "../VERSION", // При запуске из play-life-backend/ "../../VERSION", // Альтернативный путь "VERSION", // Текущая директория } for _, path := range paths { data, err := os.ReadFile(path) if err == nil { version := strings.TrimSpace(string(data)) if version != "" { return version } } } return "unknown" } func main() { // Читаем версию приложения version := readVersion() log.Printf("========================================") log.Printf("Play Life Backend v%s", version) log.Printf("========================================") // Загружаем переменные окружения из .env файла (если существует) // Сначала пробуем загрузить из корня проекта, затем из текущей директории // Игнорируем ошибку, если файл не найден godotenv.Load("../.env") // Пробуем корневой .env godotenv.Load(".env") // Пробуем локальный .env dbHost := getEnv("DB_HOST", "localhost") dbPort := getEnv("DB_PORT", "5432") dbUser := getEnv("DB_USER", "playeng") dbPassword := getEnv("DB_PASSWORD", "playeng") dbName := getEnv("DB_NAME", "playeng") // Логируем параметры подключения к БД (без пароля) log.Printf("Database connection parameters: host=%s port=%s user=%s dbname=%s", dbHost, dbPort, dbUser, dbName) dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", dbHost, dbPort, dbUser, dbPassword, dbName) var db *sql.DB var err error // Retry connection for i := 0; i < 10; i++ { db, err = sql.Open("postgres", dsn) if err == nil { err = db.Ping() if err == nil { break } } if i < 9 { time.Sleep(2 * time.Second) } } if err != nil { log.Fatal("Failed to connect to database:", err) } log.Printf("Successfully connected to database: %s@%s:%s/%s", dbUser, dbHost, dbPort, dbName) defer db.Close() // Telegram бот теперь загружается из БД при необходимости // Webhook будет настроен автоматически при сохранении bot token через UI // JWT secret from env or generate random jwtSecret := getEnv("JWT_SECRET", "") if jwtSecret == "" { // Generate random secret if not provided (not recommended for production) b := make([]byte, 32) rand.Read(b) jwtSecret = base64.StdEncoding.EncodeToString(b) log.Printf("WARNING: JWT_SECRET not set, using randomly generated secret. Set JWT_SECRET env var for production.") } app := &App{ DB: db, lastWebhookTime: make(map[int]time.Time), telegramBot: nil, telegramBotUsername: "", jwtSecret: []byte(jwtSecret), } // Инициализация Telegram бота из .env telegramBotToken := getEnv("TELEGRAM_BOT_TOKEN", "") if telegramBotToken != "" { bot, err := tgbotapi.NewBotAPI(telegramBotToken) if err != nil { log.Printf("WARNING: Failed to initialize Telegram bot: %v", err) } else { app.telegramBot = bot log.Printf("Telegram bot initialized successfully") // Получаем username бота через getMe botInfo, err := bot.GetMe() if err != nil { log.Printf("WARNING: Failed to get bot info via getMe(): %v", err) } else { app.telegramBotUsername = botInfo.UserName log.Printf("Telegram bot username: @%s", app.telegramBotUsername) } // Настраиваем webhook для единого бота webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") if webhookBaseURL != "" { webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram" log.Printf("Setting up Telegram webhook: URL=%s", webhookURL) if err := setupTelegramWebhook(telegramBotToken, webhookURL); err != nil { log.Printf("WARNING: Failed to setup Telegram webhook: %v", err) } else { log.Printf("SUCCESS: Telegram webhook configured: %s", webhookURL) } } else { log.Printf("WEBHOOK_BASE_URL not set. Webhook will not be configured.") } } } else { log.Printf("WARNING: TELEGRAM_BOT_TOKEN not set in environment") } // Инициализируем БД для play-life проекта if err := app.initPlayLifeDB(); err != nil { log.Fatal("Failed to initialize play-life database:", err) } log.Println("Play-life database initialized successfully") // Инициализируем БД для слов, словарей и конфигураций if err := app.initDB(); err != nil { log.Fatal("Failed to initialize words/dictionaries database:", err) } log.Println("Words/dictionaries database initialized successfully") // Инициализируем таблицы для авторизации if err := app.initAuthDB(); err != nil { log.Fatal("Failed to initialize auth database:", err) } log.Println("Auth database initialized successfully") // Запускаем планировщик для автоматической фиксации целей на неделю app.startWeeklyGoalsScheduler() // Запускаем планировщик для ежедневного отчета в 23:59 app.startDailyReportScheduler() r := mux.NewRouter() // Public auth routes (no authentication required) r.HandleFunc("/api/auth/register", app.registerHandler).Methods("POST", "OPTIONS") r.HandleFunc("/api/auth/login", app.loginHandler).Methods("POST", "OPTIONS") r.HandleFunc("/api/auth/refresh", app.refreshTokenHandler).Methods("POST", "OPTIONS") // Webhooks - no auth (external services) r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS") // Admin pages (basic access, consider adding auth later) r.HandleFunc("/admin", app.adminHandler).Methods("GET") r.HandleFunc("/admin.html", app.adminHandler).Methods("GET") // Protected routes (require authentication) protected := r.PathPrefix("/").Subrouter() protected.Use(app.authMiddleware) // Auth routes that need authentication protected.HandleFunc("/api/auth/logout", app.logoutHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/auth/me", app.getMeHandler).Methods("GET", "OPTIONS") // Words & dictionaries protected.HandleFunc("/api/words", app.getWordsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/words", app.addWordsHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/test/words", app.getTestWordsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/test/progress", app.updateTestProgressHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/dictionaries", app.getDictionariesHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/dictionaries", app.addDictionaryHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/dictionaries/{id}", app.updateDictionaryHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/dictionaries/{id}", app.deleteDictionaryHandler).Methods("DELETE", "OPTIONS") // Configs protected.HandleFunc("/api/configs", app.getConfigsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/configs", app.addConfigHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/configs/{id}", app.updateConfigHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/configs/{id}", app.deleteConfigHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/configs/{id}/dictionaries", app.getConfigDictionariesHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/test-configs-and-dictionaries", app.getTestConfigsAndDictionariesHandler).Methods("GET", "OPTIONS") // Projects & stats protected.HandleFunc("/api/weekly-stats", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/playlife-feed", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/weekly_goals/setup", app.weeklyGoalsSetupHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/create", app.createProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS") // Integrations protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "OPTIONS") // Todoist OAuth endpoints protected.HandleFunc("/api/integrations/todoist/oauth/connect", app.todoistOAuthConnectHandler).Methods("GET") r.HandleFunc("/api/integrations/todoist/oauth/callback", app.todoistOAuthCallbackHandler).Methods("GET") // Публичный! protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS") // Tasks protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks/{id}", app.getTaskDetailHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tasks/{id}", app.updateTaskHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/tasks/{id}", app.deleteTaskHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/complete", app.completeTaskHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/complete-and-delete", app.completeAndDeleteTaskHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS") // Admin operations protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") port := getEnv("PORT", "8080") log.Printf("Server starting on port %s", port) log.Printf("Registered public routes: /api/auth/register, /api/auth/login, /api/auth/refresh, webhooks") log.Printf("All other routes require authentication via Bearer token") log.Printf("Admin panel available at: http://localhost:%s/admin.html", port) log.Fatal(http.ListenAndServe(":"+port, r)) } func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } // getMapKeys возвращает список ключей из map func getMapKeys(m map[string]interface{}) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } // setupTelegramWebhook настраивает webhook для Telegram бота func setupTelegramWebhook(botToken, webhookURL string) error { apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken) log.Printf("Setting up Telegram webhook: apiURL=%s, webhookURL=%s", apiURL, webhookURL) payload := map[string]string{ "url": webhookURL, } jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal webhook payload: %w", err) } // Создаем HTTP клиент с таймаутом client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) if err != nil { log.Printf("ERROR: Failed to send webhook setup request: %v", err) return fmt.Errorf("failed to send webhook setup request: %w", err) } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response body: %w", err) } log.Printf("Telegram API response: status=%d, body=%s", resp.StatusCode, string(bodyBytes)) if resp.StatusCode != http.StatusOK { return fmt.Errorf("telegram API returned status %d: %s", resp.StatusCode, string(bodyBytes)) } // Декодируем из уже прочитанных байтов var result map[string]interface{} if err := json.Unmarshal(bodyBytes, &result); err != nil { return fmt.Errorf("failed to decode response: %w", err) } if ok, _ := result["ok"].(bool); !ok { description, _ := result["description"].(string) return fmt.Errorf("telegram API returned error: %s", description) } return nil } // Вспомогательные функции для расчетов func min(a, b float64) float64 { if a < b { return a } return b } func max(a, b float64) float64 { if a > b { return a } return b } func roundToTwoDecimals(val float64) float64 { return float64(int(val*100+0.5)) / 100.0 } func roundToFourDecimals(val float64) float64 { return float64(int(val*10000+0.5)) / 10000.0 } // calculateOverallProgress вычисляет общий процент выполнения на основе групп проектов по приоритетам // groups - карта приоритетов к спискам calculatedScore проектов // Возвращает указатель на float64 с общим процентом выполнения или nil, если нет данных // Если какая-то группа отсутствует, она считается как 100% func calculateOverallProgress(groups map[int][]float64) *float64 { // Всего есть 3 группы: приоритет 1, приоритет 2, приоритет 0 // Вычисляем среднее для каждой группы, если она есть // Если группы нет, считаем её как 100% groupAverages := make(map[int]float64) // Обрабатываем все 3 возможных приоритета priorities := []int{1, 2, 0} for _, priorityVal := range priorities { scores, exists := groups[priorityVal] if !exists || len(scores) == 0 { // Если группы нет, считаем как 100% groupAverages[priorityVal] = 100.0 } else { // Вычисляем среднее для группы var avg float64 // Для приоритета 1 и 2 - обычное среднее if priorityVal == 1 || priorityVal == 2 { sum := 0.0 for _, score := range scores { sum += score } avg = sum / float64(len(scores)) } else { // Для проектов без приоритета (priorityVal == 0) - специальная формула projectCount := float64(len(scores)) multiplier := 100.0 / (projectCount * 0.8) sum := 0.0 for _, score := range scores { // score уже в процентах (например, 80.0), переводим в долю (0.8) scoreAsDecimal := score / 100.0 sum += scoreAsDecimal * multiplier } avg = math.Min(120.0, sum) } groupAverages[priorityVal] = avg } } // Находим среднее между всеми тремя группами sum := 0.0 for _, priorityVal := range priorities { sum += groupAverages[priorityVal] } overallProgress := sum / 3.0 // Всегда делим на 3, так как групп всегда 3 overallProgressRounded := roundToFourDecimals(overallProgress) total := &overallProgressRounded return total } // TelegramIntegration представляет запись из таблицы telegram_integrations type TelegramIntegration struct { ID int `json:"id"` UserID int `json:"user_id"` TelegramUserID *int64 `json:"telegram_user_id,omitempty"` ChatID *string `json:"chat_id,omitempty"` StartToken *string `json:"start_token,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } // TodoistIntegration представляет запись из таблицы todoist_integrations type TodoistIntegration struct { ID int `json:"id"` UserID int `json:"user_id"` TodoistUserID *int64 `json:"todoist_user_id,omitempty"` TodoistEmail *string `json:"todoist_email,omitempty"` AccessToken *string `json:"-"` // Не отдавать в JSON! CreatedAt *time.Time `json:"created_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } // getTelegramIntegration получает telegram интеграцию из БД // getTelegramIntegrationForUser gets telegram integration for specific user func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, error) { var integration TelegramIntegration var telegramUserID sql.NullInt64 var chatID, startToken sql.NullString var createdAt, updatedAt sql.NullTime err := a.DB.QueryRow(` SELECT id, user_id, telegram_user_id, chat_id, start_token, created_at, updated_at FROM telegram_integrations WHERE user_id = $1 LIMIT 1 `, userID).Scan( &integration.ID, &integration.UserID, &telegramUserID, &chatID, &startToken, &createdAt, &updatedAt, ) if err == sql.ErrNoRows { // Создаем новую запись с start_token startTokenValue, err := generateWebhookToken() if err != nil { return nil, fmt.Errorf("failed to generate start token: %w", err) } err = a.DB.QueryRow(` INSERT INTO telegram_integrations (user_id, start_token) VALUES ($1, $2) RETURNING id, user_id, telegram_user_id, chat_id, start_token, created_at, updated_at `, userID, startTokenValue).Scan( &integration.ID, &integration.UserID, &telegramUserID, &chatID, &startToken, &createdAt, &updatedAt, ) if err != nil { return nil, fmt.Errorf("failed to create telegram integration: %w", err) } startToken = sql.NullString{String: startTokenValue, Valid: true} } else if err != nil { return nil, fmt.Errorf("failed to get telegram integration: %w", err) } // Заполняем указатели if telegramUserID.Valid { integration.TelegramUserID = &telegramUserID.Int64 } if chatID.Valid { integration.ChatID = &chatID.String } if startToken.Valid { integration.StartToken = &startToken.String } if createdAt.Valid { integration.CreatedAt = &createdAt.Time } if updatedAt.Valid { integration.UpdatedAt = &updatedAt.Time } return &integration, nil } // sendTelegramMessageToChat - отправляет сообщение в конкретный чат по chat_id func (a *App) sendTelegramMessageToChat(chatID int64, text string) error { if a.telegramBot == nil { return fmt.Errorf("telegram bot not initialized") } telegramText := regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "*$1*") msg := tgbotapi.NewMessage(chatID, telegramText) msg.ParseMode = "Markdown" _, err := a.telegramBot.Send(msg) if err != nil { // Проверяем, не заблокирован ли бот if strings.Contains(err.Error(), "blocked") || strings.Contains(err.Error(), "chat not found") || strings.Contains(err.Error(), "bot was blocked") { // Пользователь заблокировал бота - очищаем данные chatIDStr := strconv.FormatInt(chatID, 10) a.DB.Exec(` UPDATE telegram_integrations SET telegram_user_id = NULL, chat_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE chat_id = $1 `, chatIDStr) log.Printf("User blocked bot, cleared integration for chat_id=%d", chatID) } return err } log.Printf("Message sent to chat_id=%d", chatID) return nil } // sendTelegramMessageToUser - отправляет сообщение пользователю по user_id func (a *App) sendTelegramMessageToUser(userID int, text string) error { var chatID sql.NullString err := a.DB.QueryRow(` SELECT chat_id FROM telegram_integrations WHERE user_id = $1 AND chat_id IS NOT NULL `, userID).Scan(&chatID) if err == sql.ErrNoRows || !chatID.Valid { return fmt.Errorf("telegram not connected for user %d", userID) } if err != nil { return err } chatIDInt, err := strconv.ParseInt(chatID.String, 10, 64) if err != nil { return fmt.Errorf("invalid chat_id format: %w", err) } return a.sendTelegramMessageToChat(chatIDInt, text) } // getAllUsersWithTelegram - получает список всех user_id с подключенным Telegram func (a *App) getAllUsersWithTelegram() ([]int, error) { rows, err := a.DB.Query(` SELECT user_id FROM telegram_integrations WHERE chat_id IS NOT NULL AND telegram_user_id IS NOT NULL `) if err != nil { return nil, err } defer rows.Close() var userIDs []int for rows.Next() { var userID int if err := rows.Scan(&userID); err == nil { userIDs = append(userIDs, userID) } } return userIDs, nil } // utf16OffsetToUTF8 конвертирует UTF-16 offset в UTF-8 byte offset func utf16OffsetToUTF8(text string, utf16Offset int) int { utf16Runes := utf16.Encode([]rune(text)) if utf16Offset >= len(utf16Runes) { return len(text) } // Конвертируем UTF-16 кодовые единицы обратно в UTF-8 байты runes := utf16.Decode(utf16Runes[:utf16Offset]) return len(string(runes)) } // utf16LengthToUTF8 конвертирует UTF-16 length в UTF-8 byte length func utf16LengthToUTF8(text string, utf16Offset, utf16Length int) int { utf16Runes := utf16.Encode([]rune(text)) if utf16Offset+utf16Length > len(utf16Runes) { utf16Length = len(utf16Runes) - utf16Offset } if utf16Length <= 0 { return 0 } // Конвертируем UTF-16 кодовые единицы в UTF-8 байты startRunes := utf16.Decode(utf16Runes[:utf16Offset]) endRunes := utf16.Decode(utf16Runes[:utf16Offset+utf16Length]) startBytes := len(string(startRunes)) endBytes := len(string(endRunes)) return endBytes - startBytes } // processTelegramMessage обрабатывает сообщение из Telegram с использованием entities // Логика отличается от processMessage: использует entities для определения жирного текста // и не отправляет сообщение обратно в Telegram // userID может быть nil, если пользователь не определен func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, userID *int) (*ProcessedEntry, error) { fullText = strings.TrimSpace(fullText) // Регулярное выражение: project+/-score (без **) scoreRegex := regexp.MustCompile(`^([а-яА-ЯёЁ\w]+)([+-])(\d+(?:\.\d+)?)$`) // Массив для хранения извлеченных элементов {project, score} scoreNodes := make([]ProcessedNode, 0) workingText := fullText placeholderIndex := 0 // Находим все элементы, выделенные жирным шрифтом boldEntities := make([]TelegramEntity, 0) for _, entity := range entities { if entity.Type == "bold" { boldEntities = append(boldEntities, entity) } } // Сортируем в ПРЯМОМ порядке (по offset), чтобы гарантировать, что ${0} соответствует первому в тексте sort.Slice(boldEntities, func(i, j int) bool { return boldEntities[i].Offset < boldEntities[j].Offset }) // Массив для хранения данных, которые будут использоваться для замены в обратном порядке type ReplacementData struct { Start int Length int Placeholder string } replacementData := make([]ReplacementData, 0) for _, entity := range boldEntities { // Telegram использует UTF-16 для offset и length, конвертируем в UTF-8 байты start := utf16OffsetToUTF8(fullText, entity.Offset) length := utf16LengthToUTF8(fullText, entity.Offset, entity.Length) // Извлекаем чистый жирный текст if start+length > len(fullText) { continue // Пропускаем некорректные entities } boldText := strings.TrimSpace(fullText[start : start+length]) // Проверяем соответствие формату match := scoreRegex.FindStringSubmatch(boldText) if match != nil && len(match) == 4 { // Создаем элемент node project := match[1] sign := match[2] rawScore, err := strconv.ParseFloat(match[3], 64) if err != nil { log.Printf("Error parsing score: %v", err) continue } score := rawScore if sign == "-" { score = -rawScore } // Добавляем в массив nodes (по порядку) scoreNodes = append(scoreNodes, ProcessedNode{ Project: project, Score: score, }) // Создаем данные для замены replacementData = append(replacementData, ReplacementData{ Start: start, Length: length, Placeholder: fmt.Sprintf("${%d}", placeholderIndex), }) placeholderIndex++ } } // Теперь выполняем замены в ОБРАТНОМ порядке, чтобы offset не "смещались" sort.Slice(replacementData, func(i, j int) bool { return replacementData[i].Start > replacementData[j].Start }) for _, item := range replacementData { // Заменяем сегмент в workingText, используя оригинальные offset и length if item.Start+item.Length <= len(workingText) { workingText = workingText[:item.Start] + item.Placeholder + workingText[item.Start+item.Length:] } } // Удаляем пустые строки и лишние пробелы lines := strings.Split(workingText, "\n") cleanedLines := make([]string, 0) for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed != "" { cleanedLines = append(cleanedLines, trimmed) } } processedText := strings.Join(cleanedLines, "\n") // Используем текущее время в формате ISO 8601 (UTC) createdDate := time.Now().UTC().Format(time.RFC3339) // Вставляем данные в БД только если есть nodes if len(scoreNodes) > 0 { err := a.insertMessageData(processedText, createdDate, scoreNodes, userID) if err != nil { log.Printf("Error inserting message data: %v", err) return nil, fmt.Errorf("error inserting data: %w", err) } } else { // Если nodes нет, используем исходный текст для processedText processedText = fullText log.Printf("No nodes found in Telegram message, message will not be saved to database") } // Формируем ответ response := &ProcessedEntry{ Text: processedText, CreatedDate: createdDate, Nodes: scoreNodes, Raw: fullText, Markdown: fullText, // Для Telegram markdown не нужен } // НЕ отправляем сообщение обратно в Telegram (в отличие от processMessage) return response, nil } // processMessage обрабатывает текст сообщения: парсит ноды, сохраняет в БД и отправляет в Telegram func (a *App) processMessage(rawText string, userID *int) (*ProcessedEntry, error) { return a.processMessageInternal(rawText, true, userID) } // processMessageWithoutTelegram обрабатывает текст сообщения: парсит ноды, сохраняет в БД, но НЕ отправляет в Telegram func (a *App) processMessageWithoutTelegram(rawText string, userID *int) (*ProcessedEntry, error) { return a.processMessageInternal(rawText, false, userID) } // processMessageInternal - внутренняя функция обработки сообщения // sendToTelegram определяет, нужно ли отправлять сообщение в Telegram func (a *App) processMessageInternal(rawText string, sendToTelegram bool, userID *int) (*ProcessedEntry, error) { rawText = strings.TrimSpace(rawText) // Регулярное выражение для поиска **[Project][+| -][Score]** regex := regexp.MustCompile(`\*\*(.+?)([+-])([\d.]+)\*\*`) nodes := make([]ProcessedNode, 0) nodeCounter := 0 // Ищем все node и заменяем их в тексте на плейсхолдеры ${0}, ${1} и т.д. processedText := regex.ReplaceAllStringFunc(rawText, func(fullMatch string) string { matches := regex.FindStringSubmatch(fullMatch) if len(matches) != 4 { return fullMatch } projectName := strings.TrimSpace(matches[1]) sign := matches[2] scoreString := matches[3] score, err := strconv.ParseFloat(scoreString, 64) if err != nil { log.Printf("Error parsing score: %v", err) return fullMatch } if sign == "-" { score = -score } // Добавляем данные в массив nodes nodes = append(nodes, ProcessedNode{ Project: projectName, Score: score, }) placeholder := fmt.Sprintf("${%d}", nodeCounter) nodeCounter++ return placeholder }) // Удаляем пустые строки и лишние пробелы lines := strings.Split(processedText, "\n") cleanedLines := make([]string, 0) for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed != "" { cleanedLines = append(cleanedLines, trimmed) } } processedText = strings.Join(cleanedLines, "\n") // Формируем Markdown (Legacy) контент: заменяем ** на * markdownText := strings.ReplaceAll(rawText, "**", "*") // Используем текущее время createdDate := time.Now().UTC().Format(time.RFC3339) // Вставляем данные в БД только если есть nodes if len(nodes) > 0 { err := a.insertMessageData(processedText, createdDate, nodes, userID) if err != nil { log.Printf("Error inserting message data: %v", err) return nil, fmt.Errorf("error inserting data: %w", err) } } else { // Если nodes нет, используем исходный текст для processedText processedText = rawText if sendToTelegram { log.Printf("No nodes found in text, message will be sent to Telegram but not saved to database") } else { log.Printf("No nodes found in text, message will be ignored (not saved to database and not sent to Telegram)") } } // Формируем ответ response := &ProcessedEntry{ Text: processedText, CreatedDate: createdDate, Nodes: nodes, Raw: rawText, Markdown: markdownText, } // Отправляем дублирующее сообщение в Telegram только если указано if sendToTelegram && userID != nil { if err := a.sendTelegramMessageToUser(*userID, rawText); err != nil { log.Printf("Error sending Telegram message: %v", err) } } return response, nil } func (a *App) messagePostHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) // Get user ID from context (may be nil for webhook) var userIDPtr *int if userID, ok := getUserIDFromContext(r); ok { userIDPtr = &userID } // Парсим входящий запрос - может быть как {body: {text: ...}}, так и {text: ...} var rawReq map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&rawReq); err != nil { log.Printf("Error decoding message post request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Извлекаем text из разных возможных структур var rawText string if body, ok := rawReq["body"].(map[string]interface{}); ok { if text, ok := body["text"].(string); ok { rawText = text } } // Если не нашли в body, пробуем напрямую if rawText == "" { if text, ok := rawReq["text"].(string); ok { rawText = text } } // Проверка на наличие нужного поля if rawText == "" { sendErrorWithCORS(w, "Missing 'text' field in body", http.StatusBadRequest) return } // Обрабатываем сообщение response, err := a.processMessage(rawText, userIDPtr) if err != nil { log.Printf("Error processing message: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } func (a *App) insertMessageData(entryText string, createdDate string, nodes []ProcessedNode, userID *int) error { // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() // 1. UPSERT проектов projectNames := make(map[string]bool) for _, node := range nodes { projectNames[node.Project] = true } // Вставляем проекты for projectName := range projectNames { if userID != nil { // Используем более универсальный подход: проверяем существование и вставляем/обновляем var existingID int err := tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND user_id = $2 AND deleted = FALSE `, projectName, *userID).Scan(&existingID) if err == sql.ErrNoRows { // Проект не существует, создаем новый _, err = tx.Exec(` INSERT INTO projects (name, deleted, user_id) VALUES ($1, FALSE, $2) `, projectName, *userID) if err != nil { // Если ошибка из-за уникальности, пробуем обновить существующий _, err = tx.Exec(` UPDATE projects SET deleted = FALSE, user_id = COALESCE(user_id, $2) WHERE name = $1 `, projectName, *userID) if err != nil { return fmt.Errorf("failed to upsert project %s: %w", projectName, err) } } } else if err != nil { return fmt.Errorf("failed to check project %s: %w", projectName, err) } // Проект уже существует, ничего не делаем } else { // Для случая без user_id (legacy) var existingID int err := tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND deleted = FALSE `, projectName).Scan(&existingID) if err == sql.ErrNoRows { // Проект не существует, создаем новый _, err = tx.Exec(` INSERT INTO projects (name, deleted) VALUES ($1, FALSE) `, projectName) if err != nil { return fmt.Errorf("failed to insert project %s: %w", projectName, err) } } else if err != nil { return fmt.Errorf("failed to check project %s: %w", projectName, err) } // Проект уже существует, ничего не делаем } } // 2. Вставляем entry var entryID int if userID != nil { err = tx.QueryRow(` INSERT INTO entries (text, created_date, user_id) VALUES ($1, $2, $3) RETURNING id `, entryText, createdDate, *userID).Scan(&entryID) } else { err = tx.QueryRow(` INSERT INTO entries (text, created_date) VALUES ($1, $2) RETURNING id `, entryText, createdDate).Scan(&entryID) } if err != nil { return fmt.Errorf("failed to insert entry: %w", err) } // 3. Вставляем nodes for _, node := range nodes { var projectID int if userID != nil { err = tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND user_id = $2 AND deleted = FALSE `, node.Project, *userID).Scan(&projectID) } else { err = tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND deleted = FALSE `, node.Project).Scan(&projectID) } if err == sql.ErrNoRows { return fmt.Errorf("project %s not found after insert", node.Project) } else if err != nil { return fmt.Errorf("failed to find project %s: %w", node.Project, err) } // Вставляем node с user_id if userID != nil { _, err = tx.Exec(` INSERT INTO nodes (project_id, entry_id, score, user_id) VALUES ($1, $2, $3, $4) `, projectID, entryID, node.Score, *userID) } else { _, err = tx.Exec(` INSERT INTO nodes (project_id, entry_id, score) VALUES ($1, $2, $3) `, projectID, entryID, node.Score) } if err != nil { return fmt.Errorf("failed to insert node for project %s: %w", node.Project, err) } } // Обновляем materialized view после вставки данных _, err = tx.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") if err != nil { log.Printf("Warning: Failed to refresh materialized view: %v", err) // Не возвращаем ошибку, так как это не критично } // Коммитим транзакцию if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit transaction: %w", err) } return nil } // setupWeeklyGoals выполняет установку целей на неделю (без HTTP обработки) func (a *App) setupWeeklyGoals() error { // 1. Выполняем SQL запрос для установки целей setupQuery := ` WITH current_info AS ( -- Сегодня это будет 2026 год / 1 неделя SELECT EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AS c_year, EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week ), goal_metrics AS ( -- Считаем медиану на основе данных за 3 месяца (12 недель), исключая текущую неделю SELECT project_id, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_score) AS median_score FROM ( SELECT project_id, total_score, report_year, report_week, -- Нумеруем недели от новых к старым ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn FROM weekly_report_mv WHERE -- Исключаем текущую неделю и все будущие недели -- Используем сравнение (year, week) < (current_year, current_week) для корректного исключения (report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER) OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER) ) sub WHERE rn <= 12 -- Берем историю за последние 12 недель (3 месяца), исключая текущую неделю GROUP BY project_id ) INSERT INTO weekly_goals ( project_id, goal_year, goal_week, min_goal_score, max_goal_score, priority ) SELECT p.id, ci.c_year, ci.c_week, -- Если нет данных (gm.median_score IS NULL), используем 0 (значение по умолчанию) COALESCE(gm.median_score, 0) AS min_goal_score, -- Логика max_score в зависимости от приоритета (только если есть данные) CASE WHEN gm.median_score IS NULL THEN NULL WHEN p.priority = 1 THEN gm.median_score * 1.5 WHEN p.priority = 2 THEN gm.median_score * 1.3 ELSE gm.median_score * 1.2 END AS max_goal_score, p.priority FROM projects p CROSS JOIN current_info ci LEFT JOIN goal_metrics gm ON p.id = gm.project_id WHERE p.deleted = FALSE ON CONFLICT (project_id, goal_year, goal_week) DO UPDATE SET min_goal_score = EXCLUDED.min_goal_score, max_goal_score = EXCLUDED.max_goal_score, priority = EXCLUDED.priority ` _, err := a.DB.Exec(setupQuery) if err != nil { log.Printf("Error setting up weekly goals: %v", err) return fmt.Errorf("error setting up weekly goals: %w", err) } log.Println("Weekly goals setup completed successfully") // Отправляем сообщение в Telegram с зафиксированными целями if err := a.sendWeeklyGoalsTelegramMessage(); err != nil { log.Printf("Error sending weekly goals Telegram message: %v", err) // Не возвращаем ошибку, так как фиксация целей уже выполнена успешно } return nil } // getWeeklyGoalsForUser получает цели для конкретного пользователя func (a *App) getWeeklyGoalsForUser(userID int) ([]WeeklyGoalSetup, error) { selectQuery := ` SELECT p.name AS project_name, wg.min_goal_score, wg.max_goal_score FROM weekly_goals wg JOIN projects p ON wg.project_id = p.id WHERE wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AND p.deleted = FALSE AND p.user_id = $1 ORDER BY p.name ` rows, err := a.DB.Query(selectQuery, userID) if err != nil { return nil, fmt.Errorf("error querying weekly goals: %w", err) } defer rows.Close() goals := make([]WeeklyGoalSetup, 0) for rows.Next() { var goal WeeklyGoalSetup var maxGoalScore sql.NullFloat64 err := rows.Scan( &goal.ProjectName, &goal.MinGoalScore, &maxGoalScore, ) if err != nil { log.Printf("Error scanning weekly goal row: %v", err) continue } if maxGoalScore.Valid { goal.MaxGoalScore = maxGoalScore.Float64 } else { goal.MaxGoalScore = math.NaN() } goals = append(goals, goal) } return goals, nil } // sendWeeklyGoalsTelegramMessage отправляет персональные цели всем пользователям func (a *App) sendWeeklyGoalsTelegramMessage() error { userIDs, err := a.getAllUsersWithTelegram() if err != nil { return err } for _, userID := range userIDs { goals, err := a.getWeeklyGoalsForUser(userID) if err != nil { log.Printf("Error getting goals for user %d: %v", userID, err) continue } message := a.formatWeeklyGoalsMessage(goals) if message == "" { continue } if err := a.sendTelegramMessageToUser(userID, message); err != nil { log.Printf("Error sending weekly goals to user %d: %v", userID, err) } } return nil } // formatWeeklyGoalsMessage форматирует список целей в сообщение для Telegram // Формат аналогичен JS коду из n8n func (a *App) formatWeeklyGoalsMessage(goals []WeeklyGoalSetup) string { if len(goals) == 0 { return "" } // Заголовок сообщения: "Цели на неделю" markdownMessage := "*🎯 Цели на неделю:*\n\n" // Обработка каждого проекта for _, goal := range goals { // Пропускаем проекты без названия if goal.ProjectName == "" { continue } // Получаем и форматируем цели minGoal := goal.MinGoalScore maxGoal := goal.MaxGoalScore var goalText string // Форматируем текст цели, если они существуют // Проверяем, что minGoal валиден (не NaN) // В JS коде проверяется isNaN, поэтому проверяем только на NaN if !math.IsNaN(minGoal) { minGoalFormatted := fmt.Sprintf("%.2f", minGoal) // Формируем диапазон: [MIN] или [MIN - MAX] // maxGoal должен быть валиден (не NaN) для отображения диапазона if !math.IsNaN(maxGoal) { maxGoalFormatted := fmt.Sprintf("%.2f", maxGoal) // Формат: *Проект*: от 15.00 до 20.00 goalText = fmt.Sprintf(" от %s до %s", minGoalFormatted, maxGoalFormatted) } else { // Формат: *Проект*: мин. 15.00 goalText = fmt.Sprintf(" мин. %s", minGoalFormatted) } } else { // Если minGoal не установлен (NaN), пропускаем вывод цели continue } // Форматирование строки для Markdown (Legacy): *Название*: Цель markdownMessage += fmt.Sprintf("*%s*:%s\n", goal.ProjectName, goalText) } return markdownMessage } func (a *App) weeklyGoalsSetupHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) err := a.setupWeeklyGoals() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Получаем установленные цели для ответа selectQuery := ` SELECT p.name AS project_name, wg.min_goal_score, wg.max_goal_score FROM weekly_goals wg JOIN projects p ON wg.project_id = p.id WHERE wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AND p.deleted = FALSE ORDER BY p.name ` rows, err := a.DB.Query(selectQuery) if err != nil { log.Printf("Error querying weekly goals: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying weekly goals: %v", err), http.StatusInternalServerError) return } defer rows.Close() goals := make([]WeeklyGoalSetup, 0) for rows.Next() { var goal WeeklyGoalSetup var maxGoalScore sql.NullFloat64 err := rows.Scan( &goal.ProjectName, &goal.MinGoalScore, &maxGoalScore, ) if err != nil { log.Printf("Error scanning weekly goal row: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError) return } if maxGoalScore.Valid { goal.MaxGoalScore = maxGoalScore.Float64 } else { goal.MaxGoalScore = 0.0 } goals = append(goals, goal) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(goals) } // dailyReportTriggerHandler обрабатывает запрос на отправку ежедневного отчёта func (a *App) dailyReportTriggerHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) log.Printf("Manual trigger: Sending daily report") err := a.sendDailyReport() if err != nil { log.Printf("Error in manual daily report trigger: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "Daily report sent successfully", }) } func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) { // Пробуем найти файл admin.html в разных местах var adminPath string // 1. Пробуем в текущей рабочей директории if _, err := os.Stat("admin.html"); err == nil { adminPath = "admin.html" } else { // 2. Пробуем в директории play-life-backend относительно текущей директории adminPath = filepath.Join("play-life-backend", "admin.html") if _, err := os.Stat(adminPath); err != nil { // 3. Пробуем получить путь к исполняемому файлу и искать рядом if execPath, err := os.Executable(); err == nil { execDir := filepath.Dir(execPath) adminPath = filepath.Join(execDir, "admin.html") if _, err := os.Stat(adminPath); err != nil { // 4. Последняя попытка - просто "admin.html" adminPath = "admin.html" } } else { adminPath = "admin.html" } } } http.ServeFile(w, r, adminPath) } // recreateMaterializedViewHandler пересоздает materialized view с исправленной логикой ISOYEAR func (a *App) recreateMaterializedViewHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) log.Printf("Recreating materialized view weekly_report_mv with ISOYEAR fix") // Удаляем старый view dropMaterializedView := `DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv` if _, err := a.DB.Exec(dropMaterializedView); err != nil { log.Printf("Error dropping materialized view: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error dropping materialized view: %v", err), http.StatusInternalServerError) return } // Создаем новый view с ISOYEAR createMaterializedView := ` CREATE MATERIALIZED VIEW weekly_report_mv AS SELECT p.id AS project_id, agg.report_year, agg.report_week, COALESCE(agg.total_score, 0.0000) AS total_score FROM projects p LEFT JOIN ( SELECT n.project_id, EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year, EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week, SUM(n.score) AS total_score FROM nodes n JOIN entries e ON n.entry_id = e.id GROUP BY 1, 2, 3 ) agg ON p.id = agg.project_id WHERE p.deleted = FALSE ORDER BY p.id, agg.report_year, agg.report_week ` if _, err := a.DB.Exec(createMaterializedView); err != nil { log.Printf("Error creating materialized view: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating materialized view: %v", err), http.StatusInternalServerError) return } // Создаем индекс createMVIndex := ` CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week ON weekly_report_mv(project_id, report_year, report_week) ` if _, err := a.DB.Exec(createMVIndex); err != nil { log.Printf("Warning: Failed to create materialized view index: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "Materialized view recreated successfully with ISOYEAR fix", }) } func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } query := ` SELECT id AS project_id, name AS project_name, priority FROM projects WHERE deleted = FALSE AND user_id = $1 ORDER BY priority ASC NULLS LAST, project_name ` rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying projects: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying projects: %v", err), http.StatusInternalServerError) return } defer rows.Close() projects := make([]Project, 0) for rows.Next() { var project Project var priority sql.NullInt64 err := rows.Scan( &project.ProjectID, &project.ProjectName, &priority, ) if err != nil { log.Printf("Error scanning project row: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError) return } if priority.Valid { priorityVal := int(priority.Int64) project.Priority = &priorityVal } projects = append(projects, project) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(projects) } func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } _ = userID // Will be used in SQL queries // Читаем тело запроса один раз bodyBytes, err := io.ReadAll(r.Body) if err != nil { log.Printf("Error reading request body: %v", err) sendErrorWithCORS(w, "Error reading request body", http.StatusBadRequest) return } defer r.Body.Close() // Парсим входящий запрос - может быть как {body: [...]}, так и просто массив var projectsToUpdate []ProjectPriorityUpdate // Сначала пробуем декодировать как прямой массив var directArray []interface{} arrayErr := json.Unmarshal(bodyBytes, &directArray) if arrayErr == nil && len(directArray) > 0 { // Успешно декодировали как массив log.Printf("Received direct array format with %d items", len(directArray)) for _, item := range directArray { if itemMap, ok := item.(map[string]interface{}); ok { var project ProjectPriorityUpdate // Извлекаем id if idVal, ok := itemMap["id"].(float64); ok { project.ID = int(idVal) } else if idVal, ok := itemMap["id"].(int); ok { project.ID = idVal } else { log.Printf("Invalid id in request item: %v", itemMap) continue } // Извлекаем priority (может быть null, undefined, или числом) if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil { // Проверяем, не является ли это строкой "null" if strVal, ok := priorityVal.(string); ok && (strVal == "null" || strVal == "NULL") { project.Priority = nil } else if numVal, ok := priorityVal.(float64); ok { priorityInt := int(numVal) project.Priority = &priorityInt } else if numVal, ok := priorityVal.(int); ok { project.Priority = &numVal } else { project.Priority = nil } } else { project.Priority = nil } projectsToUpdate = append(projectsToUpdate, project) } } } // Если не получилось как массив (ошибка декодирования), пробуем как объект с body // НЕ пытаемся декодировать как объект, если массив декодировался успешно (даже если пустой) if len(projectsToUpdate) == 0 && arrayErr != nil { log.Printf("Failed to decode as array (error: %v), trying as object", arrayErr) var rawReq map[string]interface{} if err := json.Unmarshal(bodyBytes, &rawReq); err != nil { log.Printf("Error decoding project priority request as object: %v, body: %s", err, string(bodyBytes)) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Извлекаем массив проектов из body if body, ok := rawReq["body"].([]interface{}); ok { log.Printf("Received body format with %d items", len(body)) for _, item := range body { if itemMap, ok := item.(map[string]interface{}); ok { var project ProjectPriorityUpdate // Извлекаем id if idVal, ok := itemMap["id"].(float64); ok { project.ID = int(idVal) } else if idVal, ok := itemMap["id"].(int); ok { project.ID = idVal } else { log.Printf("Invalid id in request item: %v", itemMap) continue } // Извлекаем priority (может быть null, undefined, или числом) if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil { // Проверяем, не является ли это строкой "null" if strVal, ok := priorityVal.(string); ok && (strVal == "null" || strVal == "NULL") { project.Priority = nil } else if numVal, ok := priorityVal.(float64); ok { priorityInt := int(numVal) project.Priority = &priorityInt } else if numVal, ok := priorityVal.(int); ok { project.Priority = &numVal } else { project.Priority = nil } } else { project.Priority = nil } projectsToUpdate = append(projectsToUpdate, project) } } } } if len(projectsToUpdate) == 0 { log.Printf("No projects to update after parsing. Body was: %s", string(bodyBytes)) sendErrorWithCORS(w, "No projects to update", http.StatusBadRequest) return } log.Printf("Successfully parsed %d projects to update", len(projectsToUpdate)) // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) return } defer tx.Rollback() // Обновляем приоритеты для каждого проекта for _, project := range projectsToUpdate { if project.Priority == nil { _, err = tx.Exec(` UPDATE projects SET priority = NULL WHERE id = $1 AND user_id = $2 `, project.ID, userID) } else { _, err = tx.Exec(` UPDATE projects SET priority = $1 WHERE id = $2 AND user_id = $3 `, *project.Priority, project.ID, userID) } if err != nil { log.Printf("Error updating project %d priority: %v", project.ID, err) tx.Rollback() sendErrorWithCORS(w, fmt.Sprintf("Error updating project %d: %v", project.ID, err), http.StatusInternalServerError) return } } // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } // Возвращаем успешный ответ w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": fmt.Sprintf("Updated priorities for %d projects", len(projectsToUpdate)), "updated": len(projectsToUpdate), }) } type ProjectMoveRequest struct { ID int `json:"id"` NewName string `json:"new_name"` } type ProjectDeleteRequest struct { ID int `json:"id"` } type ProjectCreateRequest struct { Name string `json:"name"` } func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } _ = userID // Will be used in SQL queries var req ProjectMoveRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding move project request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.NewName == "" { sendErrorWithCORS(w, "new_name is required", http.StatusBadRequest) return } // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) return } defer tx.Rollback() // Ищем проект с таким именем var targetProjectID int err = tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND deleted = FALSE `, req.NewName).Scan(&targetProjectID) if err == sql.ErrNoRows { // Проект не найден - просто переименовываем текущий проект _, err = tx.Exec(` UPDATE projects SET name = $1 WHERE id = $2 `, req.NewName, req.ID) if err != nil { log.Printf("Error renaming project: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error renaming project: %v", err), http.StatusInternalServerError) return } // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project renamed successfully", "project_id": req.ID, }) return } else if err != nil { log.Printf("Error querying target project: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying target project: %v", err), http.StatusInternalServerError) return } // Проект найден - переносим данные в существующий проект finalProjectID := targetProjectID // Обновляем все nodes с project_id на целевой _, err = tx.Exec(` UPDATE nodes SET project_id = $1 WHERE project_id = $2 `, finalProjectID, req.ID) if err != nil { log.Printf("Error updating nodes: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating nodes: %v", err), http.StatusInternalServerError) return } // Обновляем weekly_goals // Сначала удаляем записи старого проекта, которые конфликтуют с записями целевого проекта // (если у целевого проекта уже есть запись для той же недели) _, err = tx.Exec(` DELETE FROM weekly_goals WHERE project_id = $1 AND EXISTS ( SELECT 1 FROM weekly_goals wg2 WHERE wg2.project_id = $2 AND wg2.goal_year = weekly_goals.goal_year AND wg2.goal_week = weekly_goals.goal_week ) `, req.ID, finalProjectID) if err != nil { log.Printf("Error deleting conflicting weekly_goals: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting conflicting weekly_goals: %v", err), http.StatusInternalServerError) return } // Теперь обновляем оставшиеся записи (те, которые не конфликтуют) _, err = tx.Exec(` UPDATE weekly_goals SET project_id = $1 WHERE project_id = $2 `, finalProjectID, req.ID) if err != nil { log.Printf("Error updating weekly_goals: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating weekly_goals: %v", err), http.StatusInternalServerError) return } // Помечаем старый проект как удаленный _, err = tx.Exec(` UPDATE projects SET deleted = TRUE WHERE id = $1 `, req.ID) if err != nil { log.Printf("Error marking project as deleted: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error marking project as deleted: %v", err), http.StatusInternalServerError) return } // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } // Обновляем materialized view _, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") if err != nil { log.Printf("Warning: Failed to refresh materialized view: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project moved successfully", "project_id": finalProjectID, }) } func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req ProjectDeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding delete project request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Verify ownership var ownerID int err := a.DB.QueryRow("SELECT user_id FROM projects WHERE id = $1", req.ID).Scan(&ownerID) if err != nil || ownerID != userID { sendErrorWithCORS(w, "Project not found", http.StatusNotFound) return } // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) return } defer tx.Rollback() // Удаляем все записи weekly_goals для этого проекта _, err = tx.Exec(` DELETE FROM weekly_goals WHERE project_id = $1 `, req.ID) if err != nil { log.Printf("Error deleting weekly_goals: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting weekly_goals: %v", err), http.StatusInternalServerError) return } // Помечаем проект как удаленный _, err = tx.Exec(` UPDATE projects SET deleted = TRUE WHERE id = $1 `, req.ID) if err != nil { log.Printf("Error marking project as deleted: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error marking project as deleted: %v", err), http.StatusInternalServerError) return } // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } // Обновляем materialized view _, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") if err != nil { log.Printf("Warning: Failed to refresh materialized view: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project deleted successfully", }) } func (a *App) createProjectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req ProjectCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding create project request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.Name == "" { sendErrorWithCORS(w, "name is required", http.StatusBadRequest) return } // Проверяем, существует ли уже проект с таким именем var existingID int err := a.DB.QueryRow(` SELECT id FROM projects WHERE name = $1 AND user_id = $2 AND deleted = FALSE `, req.Name, userID).Scan(&existingID) if err == nil { // Проект уже существует sendErrorWithCORS(w, "Project with this name already exists", http.StatusConflict) return } else if err != sql.ErrNoRows { log.Printf("Error checking project existence: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking project existence: %v", err), http.StatusInternalServerError) return } // Создаем новый проект var projectID int err = a.DB.QueryRow(` INSERT INTO projects (name, deleted, user_id) VALUES ($1, FALSE, $2) RETURNING id `, req.Name, userID).Scan(&projectID) if err != nil { log.Printf("Error creating project: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating project: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project created successfully", "project_id": projectID, "project_name": req.Name, }) } func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { // Логирование входящего запроса log.Printf("=== Todoist Webhook Request ===") log.Printf("Method: %s", r.Method) log.Printf("URL: %s", r.URL.String()) log.Printf("Path: %s", r.URL.Path) log.Printf("RemoteAddr: %s", r.RemoteAddr) if r.Method == "OPTIONS" { log.Printf("OPTIONS request, returning OK") setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) // Проверка webhook secret (если настроен) todoistWebhookSecret := getEnv("TODOIST_WEBHOOK_SECRET", "") if todoistWebhookSecret != "" { providedSecret := r.Header.Get("X-Todoist-Hmac-SHA256") if providedSecret == "" { providedSecret = r.Header.Get("X-Todoist-Webhook-Secret") } if providedSecret != todoistWebhookSecret { log.Printf("Invalid Todoist webhook secret provided") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Unauthorized", "message": "Invalid webhook secret", }) return } log.Printf("Webhook secret validated successfully") } // Читаем тело запроса bodyBytes, err := io.ReadAll(r.Body) if err != nil { log.Printf("Error reading request body: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Error reading request body", "message": "Failed to read request", }) return } log.Printf("Request body (raw): %s", string(bodyBytes)) log.Printf("Request body length: %d bytes", len(bodyBytes)) // Парсим webhook от Todoist var webhook TodoistWebhook if err := json.Unmarshal(bodyBytes, &webhook); err != nil { log.Printf("Error decoding Todoist webhook: %v", err) log.Printf("Failed to parse body as JSON: %s", string(bodyBytes)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Invalid request body", "message": "Failed to parse JSON", }) return } // Логируем структуру webhook log.Printf("Parsed webhook structure:") log.Printf(" EventName: %s", webhook.EventName) log.Printf(" EventData keys: %v", getMapKeys(webhook.EventData)) // Проверяем, что это событие закрытия задачи if webhook.EventName != "item:completed" { log.Printf("Received Todoist event '%s', ignoring (only processing 'item:completed')", webhook.EventName) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "Event ignored", "event": webhook.EventName, }) return } // Извлекаем user_id из event_data (это Todoist user_id!) var todoistUserID int64 switch v := webhook.EventData["user_id"].(type) { case float64: todoistUserID = int64(v) case string: todoistUserID, _ = strconv.ParseInt(v, 10, 64) default: log.Printf("Todoist webhook: user_id not found or invalid type in event_data") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Missing user_id in event_data", "message": "Cannot identify user", }) return } log.Printf("Todoist webhook: todoist_user_id=%d", todoistUserID) // Находим пользователя Play Life по todoist_user_id var userID int err = a.DB.QueryRow(` SELECT user_id FROM todoist_integrations WHERE todoist_user_id = $1 `, todoistUserID).Scan(&userID) if err == sql.ErrNoRows { // Пользователь не подключил Play Life — игнорируем log.Printf("Todoist webhook: no user found for todoist_user_id=%d (ignoring)", todoistUserID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "User not found (not connected)", }) return } if err != nil { log.Printf("Error finding user by todoist_user_id: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Internal server error", "message": "Database error", }) return } log.Printf("Todoist webhook: todoist_user_id=%d -> user_id=%d", todoistUserID, userID) // Извлекаем content (title) и description из event_data log.Printf("Extracting content and description from event_data...") var title, description string if content, ok := webhook.EventData["content"].(string); ok { title = strings.TrimSpace(content) log.Printf(" Found 'content' (title): '%s' (length: %d)", title, len(title)) } else { log.Printf(" 'content' not found or not a string (type: %T, value: %v)", webhook.EventData["content"], webhook.EventData["content"]) } if desc, ok := webhook.EventData["description"].(string); ok { description = strings.TrimSpace(desc) log.Printf(" Found 'description': '%s' (length: %d)", description, len(description)) } else { log.Printf(" 'description' not found or not a string (type: %T, value: %v)", webhook.EventData["description"], webhook.EventData["description"]) } // Склеиваем title и description // Логика: если есть оба - склеиваем через \n, если только один - используем его var combinedText string if title != "" && description != "" { combinedText = title + "\n" + description log.Printf(" Both title and description present, combining them") } else if title != "" { combinedText = title log.Printf(" Only title present, using title only") } else if description != "" { combinedText = description log.Printf(" Only description present, using description only") } else { combinedText = "" log.Printf(" WARNING: Both title and description are empty!") } log.Printf("Combined text result: '%s' (length: %d)", combinedText, len(combinedText)) // Проверяем, что есть хотя бы title или description if combinedText == "" { log.Printf("ERROR: Todoist webhook: no content or description found in event_data") log.Printf(" title='%s' (empty: %v), description='%s' (empty: %v)", title, title == "", description, description == "") log.Printf("Available keys in event_data: %v", getMapKeys(webhook.EventData)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Missing 'content' or 'description' in event_data", "message": "No content to process", }) return } log.Printf("Processing Todoist task: title='%s' (len=%d), description='%s' (len=%d), combined='%s' (len=%d)", title, len(title), description, len(description), combinedText, len(combinedText)) // Обрабатываем сообщение через существующую логику (без отправки в Telegram) userIDPtr := &userID log.Printf("Calling processMessageWithoutTelegram with combined text, user_id=%d...", userID) response, err := a.processMessageWithoutTelegram(combinedText, userIDPtr) if err != nil { log.Printf("ERROR processing Todoist message: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": err.Error(), "message": "Error processing message", }) return } // Проверяем наличие nodes - если их нет, игнорируем сообщение if len(response.Nodes) == 0 { log.Printf("Todoist webhook: no nodes found in message, ignoring (not saving to database and not sending to Telegram)") log.Printf("=== Todoist Webhook Request Ignored (No Nodes) ===") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "Message ignored (no nodes found)", "ignored": true, }) return } log.Printf("Successfully processed Todoist task, found %d nodes", len(response.Nodes)) if len(response.Nodes) > 0 { log.Printf("Nodes details:") for i, node := range response.Nodes { log.Printf(" Node %d: Project='%s', Score=%f", i+1, node.Project, node.Score) } // Отправляем сообщение в Telegram после успешной обработки log.Printf("Preparing to send message to Telegram...") log.Printf("Combined text to send: '%s'", combinedText) if err := a.sendTelegramMessageToUser(userID, combinedText); err != nil { log.Printf("Error sending Telegram message: %v", err) } else { log.Printf("sendTelegramMessage call completed") } } else { log.Printf("No nodes found, skipping Telegram message") } log.Printf("=== Todoist Webhook Request Completed Successfully ===") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "Task processed successfully", "result": response, }) } func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) // Парсим webhook от Telegram var update TelegramUpdate if err := json.NewDecoder(r.Body).Decode(&update); err != nil { log.Printf("Error decoding Telegram webhook: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Invalid request body", }) return } // Определяем сообщение var message *TelegramMessage if update.Message != nil { message = update.Message } else if update.EditedMessage != nil { message = update.EditedMessage } else { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } if message.From == nil { log.Printf("Telegram webhook: message without From field") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } telegramUserID := message.From.ID chatID := message.Chat.ID chatIDStr := strconv.FormatInt(chatID, 10) log.Printf("Telegram webhook: telegram_user_id=%d, chat_id=%d, text=%s", telegramUserID, chatID, message.Text) // Обработка команды /start с токеном if strings.HasPrefix(message.Text, "/start") { parts := strings.Fields(message.Text) if len(parts) > 1 { startToken := parts[1] var userID int err := a.DB.QueryRow(` SELECT user_id FROM telegram_integrations WHERE start_token = $1 `, startToken).Scan(&userID) if err == nil { // Привязываем Telegram к пользователю telegramUserIDStr := strconv.FormatInt(telegramUserID, 10) _, err = a.DB.Exec(` UPDATE telegram_integrations SET telegram_user_id = $1, chat_id = $2, start_token = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = $3 `, telegramUserIDStr, chatIDStr, userID) if err != nil { log.Printf("Error updating telegram integration: %v", err) } else { log.Printf("Telegram connected for user_id=%d", userID) // Приветственное сообщение welcomeMsg := "✅ Telegram успешно подключен к Play Life!\n\nТеперь вы будете получать уведомления и отчеты." if err := a.sendTelegramMessageToChat(chatID, welcomeMsg); err != nil { log.Printf("Error sending welcome message: %v", err) } } } else { log.Printf("Invalid start_token: %s", startToken) a.sendTelegramMessageToChat(chatID, "❌ Неверный токен. Попробуйте получить новую ссылку в приложении.") } } else { // /start без токена a.sendTelegramMessageToChat(chatID, "Привет! Для подключения используйте ссылку из приложения Play Life.") } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } // Обычное сообщение - ищем пользователя по telegram_user_id var userID int err := a.DB.QueryRow(` SELECT user_id FROM telegram_integrations WHERE telegram_user_id = $1 `, telegramUserID).Scan(&userID) if err == sql.ErrNoRows { log.Printf("User not found for telegram_user_id=%d", telegramUserID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } else if err != nil { log.Printf("Error finding user: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) return } // Обновляем chat_id (на случай переподключения) a.DB.Exec(` UPDATE telegram_integrations SET chat_id = $1, updated_at = CURRENT_TIMESTAMP WHERE user_id = $2 `, chatIDStr, userID) // Обрабатываем сообщение if message.Text == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } entities := message.Entities if entities == nil { entities = []TelegramEntity{} } userIDPtr := &userID response, err := a.processTelegramMessage(message.Text, entities, userIDPtr) if err != nil { log.Printf("Error processing message: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "result": response, }) } func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } query := ` SELECT p.name AS project_name, -- Определяем год и неделю, беря значение из той таблицы, где оно не NULL COALESCE(wr.report_year, wg.goal_year) AS report_year, COALESCE(wr.report_week, wg.goal_week) AS report_week, -- Фактический score: COALESCE(NULL, 0.0000) COALESCE(wr.total_score, 0.0000) AS total_score, -- Минимальная цель: COALESCE(NULL, 0.0000) COALESCE(wg.min_goal_score, 0.0000) AS min_goal_score, -- Максимальная цель: COALESCE(NULL, 0.0000) COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score FROM weekly_report_mv wr FULL OUTER JOIN weekly_goals wg -- Слияние по всем трем ключевым полям ON wr.project_id = wg.project_id AND wr.report_year = wg.goal_year AND wr.report_week = wg.goal_week JOIN projects p -- Присоединяем имя проекта, используя ID из той таблицы, где он не NULL ON p.id = COALESCE(wr.project_id, wg.project_id) WHERE p.deleted = FALSE AND p.user_id = $1 AND COALESCE(wr.report_year, wg.goal_year) IS NOT NULL ORDER BY report_year DESC, report_week DESC, project_name ` rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying full statistics: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying full statistics: %v", err), http.StatusInternalServerError) return } defer rows.Close() statistics := make([]FullStatisticsItem, 0) for rows.Next() { var item FullStatisticsItem err := rows.Scan( &item.ProjectName, &item.ReportYear, &item.ReportWeek, &item.TotalScore, &item.MinGoalScore, &item.MaxGoalScore, ) if err != nil { log.Printf("Error scanning full statistics row: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError) return } statistics = append(statistics, item) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(statistics) } // getTelegramIntegrationHandler возвращает текущую telegram интеграцию с deep link func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } integration, err := a.getTelegramIntegrationForUser(userID) if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to get telegram integration: %v", err), http.StatusInternalServerError) return } // Генерируем start_token если его нет if integration.StartToken == nil || *integration.StartToken == "" { token, err := generateWebhookToken() if err == nil { _, _ = a.DB.Exec(` UPDATE telegram_integrations SET start_token = $1, updated_at = CURRENT_TIMESTAMP WHERE user_id = $2 `, token, userID) integration.StartToken = &token } } // Формируем deep link var deepLink string if a.telegramBotUsername != "" && integration.StartToken != nil { deepLink = fmt.Sprintf("https://t.me/%s?start=%s", a.telegramBotUsername, *integration.StartToken) } isConnected := integration.ChatID != nil && integration.TelegramUserID != nil w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "id": integration.ID, "telegram_user_id": integration.TelegramUserID, "is_connected": isConnected, "deep_link": deepLink, }) } // updateTelegramIntegrationHandler больше не используется (bot_token теперь в .env) // Оставлен для совместимости, возвращает ошибку func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) sendErrorWithCORS(w, "Bot token is now configured via TELEGRAM_BOT_TOKEN environment variable", http.StatusBadRequest) } // OAuthStateClaims структура для OAuth state JWT type OAuthStateClaims struct { UserID int `json:"user_id"` Type string `json:"type"` jwt.RegisteredClaims } // generateOAuthState генерирует JWT state для OAuth func generateOAuthState(userID int, jwtSecret []byte) (string, error) { claims := OAuthStateClaims{ UserID: userID, Type: "todoist_oauth", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // 1 день IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(jwtSecret) } // validateOAuthState проверяет и извлекает user_id из JWT state func validateOAuthState(stateString string, jwtSecret []byte) (int, error) { token, err := jwt.ParseWithClaims(stateString, &OAuthStateClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return jwtSecret, nil }) if err != nil { return 0, err } claims, ok := token.Claims.(*OAuthStateClaims) if !ok || !token.Valid { return 0, fmt.Errorf("invalid token") } if claims.Type != "todoist_oauth" { return 0, fmt.Errorf("wrong token type") } return claims.UserID, nil } // exchangeCodeForToken обменивает OAuth code на access_token func exchangeCodeForToken(code, redirectURI, clientID, clientSecret string) (string, error) { data := url.Values{} data.Set("client_id", clientID) data.Set("client_secret", clientSecret) data.Set("code", code) data.Set("redirect_uri", redirectURI) resp, err := http.PostForm("https://todoist.com/oauth/access_token", data) if err != nil { return "", fmt.Errorf("failed to exchange code: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("token exchange failed: %s", string(body)) } var result struct { AccessToken string `json:"access_token"` Error string `json:"error"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to decode response: %w", err) } if result.Error != "" { return "", fmt.Errorf("token exchange error: %s", result.Error) } return result.AccessToken, nil } // getTodoistUserInfo получает информацию о пользователе через Sync API func getTodoistUserInfo(accessToken string) (struct { ID int64 Email string }, error) { var userInfo struct { ID int64 Email string } // Формируем правильный запрос к Sync API data := url.Values{} data.Set("sync_token", "*") data.Set("resource_types", `["user"]`) req, err := http.NewRequest("POST", "https://api.todoist.com/sync/v9/sync", strings.NewReader(data.Encode())) if err != nil { log.Printf("Todoist API: failed to create request: %v", err) return userInfo, err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "PlayLife") log.Printf("Todoist API: requesting user info from sync/v9/sync") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { log.Printf("Todoist API: request failed: %v", err) return userInfo, fmt.Errorf("failed to get user info: %w", err) } defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) log.Printf("Todoist API: response status=%d, body=%s", resp.StatusCode, string(bodyBytes)) if resp.StatusCode != http.StatusOK { return userInfo, fmt.Errorf("get user info failed (status %d): %s", resp.StatusCode, string(bodyBytes)) } // Парсим ответ - в Sync API user может быть объектом или массивом var result map[string]interface{} if err := json.Unmarshal(bodyBytes, &result); err != nil { log.Printf("Todoist API: failed to parse JSON: %v, body: %s", err, string(bodyBytes)) return userInfo, fmt.Errorf("failed to decode user info: %w", err) } log.Printf("Todoist API: parsed response keys: %v", getMapKeys(result)) // Функция для извлечения ID из разных типов extractID := func(idValue interface{}) int64 { switch v := idValue.(type) { case float64: return int64(v) case int64: return v case int: return int64(v) case string: if id, err := strconv.ParseInt(v, 10, 64); err == nil { return id } } return 0 } // Проверяем разные варианты структуры ответа if userObj, ok := result["user"].(map[string]interface{}); ok { // Один объект user userInfo.ID = extractID(userObj["id"]) if email, ok := userObj["email"].(string); ok { userInfo.Email = email } } else if usersArr, ok := result["user"].([]interface{}); ok && len(usersArr) > 0 { // Массив users, берем первый if userObj, ok := usersArr[0].(map[string]interface{}); ok { userInfo.ID = extractID(userObj["id"]) if email, ok := userObj["email"].(string); ok { userInfo.Email = email } } } else { log.Printf("Todoist API: user not found in response, available keys: %v", getMapKeys(result)) return userInfo, fmt.Errorf("user not found in response") } if userInfo.ID == 0 || userInfo.Email == "" { log.Printf("Todoist API: incomplete user info: ID=%d, Email=%s", userInfo.ID, userInfo.Email) return userInfo, fmt.Errorf("incomplete user info: ID=%d, Email=%s", userInfo.ID, userInfo.Email) } log.Printf("Todoist API: successfully got user info: ID=%d, Email=%s", userInfo.ID, userInfo.Email) return userInfo, nil } // todoistOAuthConnectHandler инициирует OAuth flow func (a *App) todoistOAuthConnectHandler(w http.ResponseWriter, r *http.Request) { setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } clientID := getEnv("TODOIST_CLIENT_ID", "") clientSecret := getEnv("TODOIST_CLIENT_SECRET", "") baseURL := getEnv("WEBHOOK_BASE_URL", "") if clientID == "" || clientSecret == "" { sendErrorWithCORS(w, "TODOIST_CLIENT_ID and TODOIST_CLIENT_SECRET must be configured", http.StatusInternalServerError) return } if baseURL == "" { sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError) return } redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback" state, err := generateOAuthState(userID, a.jwtSecret) if err != nil { log.Printf("Todoist OAuth: failed to generate state: %v", err) sendErrorWithCORS(w, "Failed to generate OAuth state", http.StatusInternalServerError) return } authURL := fmt.Sprintf( "https://todoist.com/oauth/authorize?client_id=%s&scope=data:read_write&state=%s&redirect_uri=%s", url.QueryEscape(clientID), url.QueryEscape(state), url.QueryEscape(redirectURI), ) log.Printf("Todoist OAuth: returning auth URL for user_id=%d", userID) // Возвращаем JSON с URL для редиректа (frontend сделает редирект) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "auth_url": authURL, }) } // todoistOAuthCallbackHandler обрабатывает OAuth callback func (a *App) todoistOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { frontendURL := getEnv("WEBHOOK_BASE_URL", "") redirectSuccess := frontendURL + "/?integration=todoist&status=connected" redirectError := frontendURL + "/?integration=todoist&status=error" clientID := getEnv("TODOIST_CLIENT_ID", "") clientSecret := getEnv("TODOIST_CLIENT_SECRET", "") baseURL := getEnv("WEBHOOK_BASE_URL", "") if clientID == "" || clientSecret == "" || baseURL == "" { log.Printf("Todoist OAuth: missing configuration") http.Redirect(w, r, redirectError+"&message=config_error", http.StatusTemporaryRedirect) return } redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback" // Проверяем state state := r.URL.Query().Get("state") userID, err := validateOAuthState(state, a.jwtSecret) if err != nil { log.Printf("Todoist OAuth: invalid state: %v", err) http.Redirect(w, r, redirectError+"&message=invalid_state", http.StatusTemporaryRedirect) return } // Получаем code code := r.URL.Query().Get("code") if code == "" { log.Printf("Todoist OAuth: no code in callback") http.Redirect(w, r, redirectError+"&message=no_code", http.StatusTemporaryRedirect) return } // Обмениваем code на access_token accessToken, err := exchangeCodeForToken(code, redirectURI, clientID, clientSecret) if err != nil { log.Printf("Todoist OAuth: token exchange failed: %v", err) http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect) return } // Получаем информацию о пользователе todoistUser, err := getTodoistUserInfo(accessToken) if err != nil { log.Printf("Todoist OAuth: get user info failed: %v", err) http.Redirect(w, r, redirectError+"&message=user_info_failed", http.StatusTemporaryRedirect) return } log.Printf("Todoist OAuth: user_id=%d connected todoist_user_id=%d email=%s", userID, todoistUser.ID, todoistUser.Email) // Сохраняем в БД _, err = a.DB.Exec(` INSERT INTO todoist_integrations (user_id, todoist_user_id, todoist_email, access_token) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id) DO UPDATE SET todoist_user_id = $2, todoist_email = $3, access_token = $4, updated_at = CURRENT_TIMESTAMP `, userID, todoistUser.ID, todoistUser.Email, accessToken) if err != nil { log.Printf("Todoist OAuth: DB error: %v", err) http.Redirect(w, r, redirectError+"&message=db_error", http.StatusTemporaryRedirect) return } // Редирект на страницу интеграций http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect) } // getTodoistStatusHandler возвращает статус подключения Todoist func (a *App) getTodoistStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var todoistEmail sql.NullString err := a.DB.QueryRow(` SELECT todoist_email FROM todoist_integrations WHERE user_id = $1 AND access_token IS NOT NULL `, userID).Scan(&todoistEmail) if err == sql.ErrNoRows || !todoistEmail.Valid { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "connected": false, }) return } if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to get status: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "connected": true, "todoist_email": todoistEmail.String, }) } // ============================================ // Tasks handlers // ============================================ // getTasksHandler возвращает список задач пользователя func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Запрос с получением всех необходимых данных для группировки и отображения query := ` SELECT t.id, t.name, t.completed, t.last_completed_at, t.next_show_at, t.repetition_period::text, t.repetition_date, t.progression_base, COALESCE(( SELECT COUNT(*) FROM tasks st WHERE st.parent_task_id = t.id AND st.deleted = FALSE ), 0) as subtasks_count, COALESCE( (SELECT array_agg(DISTINCT p.name) FILTER (WHERE p.name IS NOT NULL) FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = t.id), ARRAY[]::text[] ) as project_names, COALESCE( (SELECT array_agg(DISTINCT p.name) FILTER (WHERE p.name IS NOT NULL) FROM tasks st JOIN reward_configs rc ON rc.task_id = st.id JOIN projects p ON rc.project_id = p.id WHERE st.parent_task_id = t.id AND st.deleted = FALSE), ARRAY[]::text[] ) as subtask_project_names FROM tasks t WHERE t.user_id = $1 AND t.parent_task_id IS NULL AND t.deleted = FALSE ORDER BY CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN 0 ELSE 1 END, t.name ` rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying tasks: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying tasks: %v", err), http.StatusInternalServerError) return } defer rows.Close() tasks := make([]Task, 0) for rows.Next() { var task Task var lastCompletedAt sql.NullString var nextShowAt sql.NullString var repetitionPeriod sql.NullString var repetitionDate sql.NullString var progressionBase sql.NullFloat64 var projectNames pq.StringArray var subtaskProjectNames pq.StringArray err := rows.Scan( &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &repetitionPeriod, &repetitionDate, &progressionBase, &task.SubtasksCount, &projectNames, &subtaskProjectNames, ) if err != nil { log.Printf("Error scanning task: %v", err) continue } if lastCompletedAt.Valid { task.LastCompletedAt = &lastCompletedAt.String } if nextShowAt.Valid { task.NextShowAt = &nextShowAt.String } if repetitionPeriod.Valid { task.RepetitionPeriod = &repetitionPeriod.String } if repetitionDate.Valid { task.RepetitionDate = &repetitionDate.String } if progressionBase.Valid { task.HasProgression = true task.ProgressionBase = &progressionBase.Float64 } else { task.HasProgression = false } // Объединяем проекты из основной задачи и подзадач allProjects := make(map[string]bool) for _, pn := range projectNames { if pn != "" { allProjects[pn] = true } } for _, pn := range subtaskProjectNames { if pn != "" { allProjects[pn] = true } } task.ProjectNames = make([]string, 0, len(allProjects)) for pn := range allProjects { task.ProjectNames = append(task.ProjectNames, pn) } tasks = append(tasks, task) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tasks) } // getTaskDetailHandler возвращает детальную информацию о задаче func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } // Получаем основную задачу var task Task var rewardMessage sql.NullString var progressionBase sql.NullFloat64 var lastCompletedAt sql.NullString var nextShowAt sql.NullString var repetitionPeriod sql.NullString var repetitionDate sql.NullString // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL var repetitionPeriodStr string var repetitionDateStr string err = a.DB.QueryRow(` SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base, CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period, COALESCE(repetition_date, '') as repetition_date FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE `, taskID, userID).Scan( &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, ) log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr) // Преобразуем в sql.NullString для совместимости if repetitionPeriodStr != "" { repetitionPeriod = sql.NullString{String: repetitionPeriodStr, Valid: true} } else { repetitionPeriod = sql.NullString{Valid: false} } if repetitionDateStr != "" { repetitionDate = sql.NullString{String: repetitionDateStr, Valid: true} } else { repetitionDate = sql.NullString{Valid: false} } if err == sql.ErrNoRows { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error querying task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying task: %v", err), http.StatusInternalServerError) return } if rewardMessage.Valid { task.RewardMessage = &rewardMessage.String } if progressionBase.Valid { task.ProgressionBase = &progressionBase.Float64 } if lastCompletedAt.Valid { task.LastCompletedAt = &lastCompletedAt.String } if nextShowAt.Valid { task.NextShowAt = &nextShowAt.String } if repetitionPeriod.Valid && repetitionPeriod.String != "" { task.RepetitionPeriod = &repetitionPeriod.String log.Printf("Task %d has repetition_period: %s", task.ID, repetitionPeriod.String) } else { log.Printf("Task %d has no repetition_period (Valid: %v, String: '%s')", task.ID, repetitionPeriod.Valid, repetitionPeriod.String) } if repetitionDate.Valid && repetitionDate.String != "" { task.RepetitionDate = &repetitionDate.String log.Printf("Task %d has repetition_date: %s", task.ID, repetitionDate.String) } // Получаем награды основной задачи rewards := make([]Reward, 0) rewardRows, err := a.DB.Query(` SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = $1 ORDER BY rc.position `, taskID) if err != nil { log.Printf("Error querying rewards: %v", err) } else { defer rewardRows.Close() for rewardRows.Next() { var reward Reward err := rewardRows.Scan(&reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning reward: %v", err) continue } rewards = append(rewards, reward) } } // Получаем подзадачи subtasks := make([]Subtask, 0) subtaskRows, err := a.DB.Query(` SELECT id, name, completed, last_completed_at, reward_message, progression_base FROM tasks WHERE parent_task_id = $1 AND deleted = FALSE ORDER BY id `, taskID) if err != nil { log.Printf("Error querying subtasks: %v", err) } else { defer subtaskRows.Close() for subtaskRows.Next() { var subtaskTask Task var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 var subtaskLastCompletedAt sql.NullString err := subtaskRows.Scan( &subtaskTask.ID, &subtaskTask.Name, &subtaskTask.Completed, &subtaskLastCompletedAt, &subtaskRewardMessage, &subtaskProgressionBase, ) if err != nil { log.Printf("Error scanning subtask: %v", err) continue } if subtaskRewardMessage.Valid { subtaskTask.RewardMessage = &subtaskRewardMessage.String } if subtaskProgressionBase.Valid { subtaskTask.ProgressionBase = &subtaskProgressionBase.Float64 } if subtaskLastCompletedAt.Valid { subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String } // Получаем награды подзадачи subtaskRewards := make([]Reward, 0) subtaskRewardRows, err := a.DB.Query(` SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = $1 ORDER BY rc.position `, subtaskTask.ID) if err == nil { for subtaskRewardRows.Next() { var reward Reward err := subtaskRewardRows.Scan(&reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning subtask reward: %v", err) continue } subtaskRewards = append(subtaskRewards, reward) } subtaskRewardRows.Close() } subtasks = append(subtasks, Subtask{ Task: subtaskTask, Rewards: subtaskRewards, }) } } response := TaskDetail{ Task: task, Rewards: rewards, Subtasks: subtasks, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // findProjectByName находит проект по имени (регистронезависимо) или возвращает ошибку func (a *App) findProjectByName(projectName string, userID int) (int, error) { var projectID int err := a.DB.QueryRow(` SELECT id FROM projects WHERE LOWER(name) = LOWER($1) AND user_id = $2 AND deleted = FALSE `, projectName, userID).Scan(&projectID) if err == sql.ErrNoRows { return 0, fmt.Errorf("project not found: %s", projectName) } if err != nil { return 0, fmt.Errorf("error finding project: %w", err) } return projectID, nil } // findProjectByNameTx находит проект по имени в транзакции func (a *App) findProjectByNameTx(tx *sql.Tx, projectName string, userID int) (int, error) { var projectID int err := tx.QueryRow(` SELECT id FROM projects WHERE LOWER(name) = LOWER($1) AND user_id = $2 AND deleted = FALSE `, projectName, userID).Scan(&projectID) if err == sql.ErrNoRows { return 0, fmt.Errorf("project not found: %s", projectName) } if err != nil { return 0, fmt.Errorf("error finding project: %w", err) } return projectID, nil } // createTaskHandler создает новую задачу func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req TaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding task request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Валидация if len(strings.TrimSpace(req.Name)) < 1 { sendErrorWithCORS(w, "Task name is required and must be at least 1 character", http.StatusBadRequest) return } // Проверяем, что все rewards имеют project_name for _, reward := range req.Rewards { if strings.TrimSpace(reward.ProjectName) == "" { sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) return } } // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) return } defer tx.Rollback() // Создаем основную задачу var taskID int var rewardMessage sql.NullString var progressionBase sql.NullFloat64 var repetitionPeriod sql.NullString var repetitionDate sql.NullString if req.RewardMessage != nil { rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true} } if req.ProgressionBase != nil { progressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } if req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "" { repetitionPeriod = sql.NullString{String: strings.TrimSpace(*req.RepetitionPeriod), Valid: true} log.Printf("Creating task with repetition_period: %s", repetitionPeriod.String) } else { log.Printf("Creating task without repetition_period (req.RepetitionPeriod: %v)", req.RepetitionPeriod) } if req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "" { repetitionDate = sql.NullString{String: strings.TrimSpace(*req.RepetitionDate), Valid: true} log.Printf("Creating task with repetition_date: %s", repetitionDate.String) } // Используем CAST для преобразования строки в INTERVAL var repetitionPeriodValue interface{} if repetitionPeriod.Valid { repetitionPeriodValue = repetitionPeriod.String } else { repetitionPeriodValue = nil } // Используем условный SQL для обработки NULL значений var insertSQL string var insertArgs []interface{} if repetitionPeriod.Valid { // Для repetition_period выставляем сегодняшнюю дату now := time.Now() insertSQL = ` INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted) VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE) RETURNING id ` insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now} } else if repetitionDate.Valid { // Вычисляем next_show_at для задачи с repetition_date nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now()) if nextShowAt != nil { insertSQL = ` INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted) VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE) RETURNING id ` insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt} } else { insertSQL = ` INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted) VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE) RETURNING id ` insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String} } } else { insertSQL = ` INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted) VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE) RETURNING id ` insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase} } err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID) if err != nil { log.Printf("Error creating task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating task: %v", err), http.StatusInternalServerError) return } // Создаем награды для основной задачи for _, rewardReq := range req.Rewards { projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) if err != nil { log.Printf("Error finding project %s: %v", rewardReq.ProjectName, err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } _, err = tx.Exec(` INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) VALUES ($1, $2, $3, $4, $5) `, rewardReq.Position, taskID, projectID, rewardReq.Value, rewardReq.UseProgression) if err != nil { log.Printf("Error creating reward: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating reward: %v", err), http.StatusInternalServerError) return } } // Создаем подзадачи for _, subtaskReq := range req.Subtasks { var subtaskName sql.NullString var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} } if subtaskReq.RewardMessage != nil { subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true} } if req.ProgressionBase != nil { subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } var subtaskID int err = tx.QueryRow(` INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted) VALUES ($1, $2, $3, $4, $5, 0, FALSE) RETURNING id `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase).Scan(&subtaskID) if err != nil { log.Printf("Error creating subtask: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask: %v", err), http.StatusInternalServerError) return } // Создаем награды для подзадачи for _, rewardReq := range subtaskReq.Rewards { if strings.TrimSpace(rewardReq.ProjectName) == "" { sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) return } projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) if err != nil { log.Printf("Error finding project %s for subtask: %v", rewardReq.ProjectName, err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } _, err = tx.Exec(` INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) VALUES ($1, $2, $3, $4, $5) `, rewardReq.Position, subtaskID, projectID, rewardReq.Value, rewardReq.UseProgression) if err != nil { log.Printf("Error creating subtask reward: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError) return } } } // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } // Возвращаем созданную задачу var createdTask Task var lastCompletedAt sql.NullString var createdRepetitionPeriod sql.NullString var createdRepetitionDate sql.NullString err = a.DB.QueryRow(` SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text, repetition_date FROM tasks WHERE id = $1 `, taskID).Scan( &createdTask.ID, &createdTask.Name, &createdTask.Completed, &lastCompletedAt, &rewardMessage, &progressionBase, &createdRepetitionPeriod, &createdRepetitionDate, ) if err != nil { log.Printf("Error fetching created task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error fetching created task: %v", err), http.StatusInternalServerError) return } if rewardMessage.Valid { createdTask.RewardMessage = &rewardMessage.String } if progressionBase.Valid { createdTask.ProgressionBase = &progressionBase.Float64 } if lastCompletedAt.Valid { createdTask.LastCompletedAt = &lastCompletedAt.String } if createdRepetitionPeriod.Valid { createdTask.RepetitionPeriod = &createdRepetitionPeriod.String } if createdRepetitionDate.Valid { createdTask.RepetitionDate = &createdRepetitionDate.String } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(createdTask) } // updateTaskHandler обновляет существующую задачу func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } // Проверяем владельца var ownerID int err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1", taskID).Scan(&ownerID) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking task ownership: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) return } var req TaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding task request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Валидация if len(strings.TrimSpace(req.Name)) < 1 { sendErrorWithCORS(w, "Task name is required and must be at least 1 character", http.StatusBadRequest) return } // Проверяем, что все rewards имеют project_name for _, reward := range req.Rewards { if strings.TrimSpace(reward.ProjectName) == "" { sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) return } } // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) return } defer tx.Rollback() // Обновляем основную задачу var rewardMessage sql.NullString var progressionBase sql.NullFloat64 var repetitionPeriod sql.NullString var repetitionDate sql.NullString if req.RewardMessage != nil { rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true} } if req.ProgressionBase != nil { progressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } if req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "" { repetitionPeriod = sql.NullString{String: strings.TrimSpace(*req.RepetitionPeriod), Valid: true} log.Printf("Updating task %d with repetition_period: %s", taskID, repetitionPeriod.String) } else { log.Printf("Updating task %d without repetition_period (req.RepetitionPeriod: %v)", taskID, req.RepetitionPeriod) } if req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "" { repetitionDate = sql.NullString{String: strings.TrimSpace(*req.RepetitionDate), Valid: true} log.Printf("Updating task %d with repetition_date: %s", taskID, repetitionDate.String) } // Используем условный SQL для обработки NULL значений var updateSQL string var updateArgs []interface{} if repetitionPeriod.Valid { // Для repetition_period выставляем сегодняшнюю дату now := time.Now() updateSQL = ` UPDATE tasks SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, next_show_at = $5 WHERE id = $6 ` updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, taskID} } else if repetitionDate.Valid { // Вычисляем next_show_at для задачи с repetition_date nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now()) if nextShowAt != nil { updateSQL = ` UPDATE tasks SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, next_show_at = $5 WHERE id = $6 ` updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, taskID} } else { updateSQL = ` UPDATE tasks SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4 WHERE id = $5 ` updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, taskID} } } else { updateSQL = ` UPDATE tasks SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = NULL, next_show_at = NULL WHERE id = $4 ` updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, taskID} } _, err = tx.Exec(updateSQL, updateArgs...) if err != nil { log.Printf("Error updating task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating task: %v", err), http.StatusInternalServerError) return } // Удаляем старые награды основной задачи _, err = tx.Exec("DELETE FROM reward_configs WHERE task_id = $1", taskID) if err != nil { log.Printf("Error deleting old rewards: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting old rewards: %v", err), http.StatusInternalServerError) return } // Вставляем новые награды for _, rewardReq := range req.Rewards { projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) if err != nil { log.Printf("Error finding project %s: %v", rewardReq.ProjectName, err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } _, err = tx.Exec(` INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) VALUES ($1, $2, $3, $4, $5) `, rewardReq.Position, taskID, projectID, rewardReq.Value, rewardReq.UseProgression) if err != nil { log.Printf("Error creating reward: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating reward: %v", err), http.StatusInternalServerError) return } } // Получаем список текущих подзадач currentSubtaskIDs := make(map[int]bool) rows, err := tx.Query("SELECT id FROM tasks WHERE parent_task_id = $1 AND deleted = FALSE", taskID) if err == nil { for rows.Next() { var id int if err := rows.Scan(&id); err == nil { currentSubtaskIDs[id] = true } } rows.Close() } // Обрабатываем подзадачи из запроса subtaskIDsInRequest := make(map[int]bool) for _, subtaskReq := range req.Subtasks { if subtaskReq.ID != nil { subtaskIDsInRequest[*subtaskReq.ID] = true // Обновляем существующую подзадачу var subtaskName sql.NullString var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} } if subtaskReq.RewardMessage != nil { subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true} } if req.ProgressionBase != nil { subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } _, err = tx.Exec(` UPDATE tasks SET name = $1, reward_message = $2, progression_base = $3 WHERE id = $4 AND parent_task_id = $5 `, subtaskName, subtaskRewardMessage, subtaskProgressionBase, *subtaskReq.ID, taskID) if err != nil { log.Printf("Error updating subtask: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating subtask: %v", err), http.StatusInternalServerError) return } // Удаляем старые награды подзадачи _, err = tx.Exec("DELETE FROM reward_configs WHERE task_id = $1", *subtaskReq.ID) if err != nil { log.Printf("Error deleting old subtask rewards: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting old subtask rewards: %v", err), http.StatusInternalServerError) return } // Вставляем новые награды подзадачи for _, rewardReq := range subtaskReq.Rewards { if strings.TrimSpace(rewardReq.ProjectName) == "" { sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) return } projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) if err != nil { log.Printf("Error finding project %s for subtask: %v", rewardReq.ProjectName, err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } _, err = tx.Exec(` INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) VALUES ($1, $2, $3, $4, $5) `, rewardReq.Position, *subtaskReq.ID, projectID, rewardReq.Value, rewardReq.UseProgression) if err != nil { log.Printf("Error creating subtask reward: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError) return } } } else { // Создаем новую подзадачу var subtaskName sql.NullString var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} } if subtaskReq.RewardMessage != nil { subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true} } if req.ProgressionBase != nil { subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } var subtaskID int err = tx.QueryRow(` INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted) VALUES ($1, $2, $3, $4, $5, 0, FALSE) RETURNING id `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase).Scan(&subtaskID) if err != nil { log.Printf("Error creating subtask: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask: %v", err), http.StatusInternalServerError) return } // Создаем награды для новой подзадачи for _, rewardReq := range subtaskReq.Rewards { if strings.TrimSpace(rewardReq.ProjectName) == "" { sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) return } projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) if err != nil { log.Printf("Error finding project %s for new subtask: %v", rewardReq.ProjectName, err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } _, err = tx.Exec(` INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) VALUES ($1, $2, $3, $4, $5) `, rewardReq.Position, subtaskID, projectID, rewardReq.Value, rewardReq.UseProgression) if err != nil { log.Printf("Error creating subtask reward: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError) return } } } } // Помечаем подзадачи, которые были в БД, но не пришли в запросе, как deleted for subtaskID := range currentSubtaskIDs { if !subtaskIDsInRequest[subtaskID] { _, err = tx.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1", subtaskID) if err != nil { log.Printf("Error marking subtask as deleted: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error marking subtask as deleted: %v", err), http.StatusInternalServerError) return } } } // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } // Возвращаем обновленную задачу var updatedTask Task var lastCompletedAt sql.NullString var updatedRepetitionPeriod sql.NullString var updatedRepetitionDate sql.NullString err = a.DB.QueryRow(` SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text, repetition_date FROM tasks WHERE id = $1 `, taskID).Scan( &updatedTask.ID, &updatedTask.Name, &updatedTask.Completed, &lastCompletedAt, &rewardMessage, &progressionBase, &updatedRepetitionPeriod, &updatedRepetitionDate, ) if err != nil { log.Printf("Error fetching updated task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error fetching updated task: %v", err), http.StatusInternalServerError) return } if rewardMessage.Valid { updatedTask.RewardMessage = &rewardMessage.String } if progressionBase.Valid { updatedTask.ProgressionBase = &progressionBase.Float64 } if lastCompletedAt.Valid { updatedTask.LastCompletedAt = &lastCompletedAt.String } if updatedRepetitionPeriod.Valid { updatedTask.RepetitionPeriod = &updatedRepetitionPeriod.String } if updatedRepetitionDate.Valid { updatedTask.RepetitionDate = &updatedRepetitionDate.String } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(updatedTask) } // deleteTaskHandler удаляет задачу (помечает как deleted) func (a *App) deleteTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } // Проверяем владельца var ownerID int err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1", taskID).Scan(&ownerID) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking task ownership: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) return } // Помечаем задачу как удаленную _, err = a.DB.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1 AND user_id = $2", taskID, userID) if err != nil { log.Printf("Error deleting task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting task: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Task deleted successfully", }) } // completeTaskHandler выполняет задачу func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } var req CompleteTaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding complete task request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Получаем задачу и проверяем владельца var task Task var rewardMessage sql.NullString var progressionBase sql.NullFloat64 var repetitionPeriod sql.NullString var repetitionDate sql.NullString var ownerID int err = a.DB.QueryRow(` SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id FROM tasks WHERE id = $1 AND deleted = FALSE `, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error querying task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying task: %v", err), http.StatusInternalServerError) return } if ownerID != userID { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } // Валидация: если progression_base != null, то value обязателен if progressionBase.Valid && req.Value == nil { sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest) return } if rewardMessage.Valid { task.RewardMessage = &rewardMessage.String } if progressionBase.Valid { task.ProgressionBase = &progressionBase.Float64 } // Получаем награды основной задачи rewardRows, err := a.DB.Query(` SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = $1 ORDER BY rc.position `, taskID) if err != nil { log.Printf("Error querying rewards: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying rewards: %v", err), http.StatusInternalServerError) return } defer rewardRows.Close() rewards := make([]Reward, 0) for rewardRows.Next() { var reward Reward err := rewardRows.Scan(&reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning reward: %v", err) continue } rewards = append(rewards, reward) } // Вычисляем score для каждой награды и формируем строки для подстановки rewardStrings := make(map[int]string) for _, reward := range rewards { var score float64 if reward.UseProgression && progressionBase.Valid && req.Value != nil { score = (*req.Value / progressionBase.Float64) * reward.Value } else { score = reward.Value } // Формируем строку награды var rewardStr string if score >= 0 { rewardStr = fmt.Sprintf("**%s+%.4g**", reward.ProjectName, score) } else { // Убираем знак минуса из числа (используем абсолютное значение) rewardStr = fmt.Sprintf("**%s-%.4g**", reward.ProjectName, math.Abs(score)) } rewardStrings[reward.Position] = rewardStr } // Функция для замены плейсхолдеров в сообщении награды replaceRewardPlaceholders := func(message string, rewardStrings map[int]string) string { result := message // Сначала сохраняем экранированные плейсхолдеры \$0, \$1 и т.д. во временные маркеры escapedMarkers := make(map[string]string) for i := 0; i < 100; i++ { escaped := fmt.Sprintf(`\$%d`, i) marker := fmt.Sprintf(`__ESCAPED_DOLLAR_%d__`, i) if strings.Contains(result, escaped) { escapedMarkers[marker] = escaped result = strings.ReplaceAll(result, escaped, marker) } } // Заменяем ${0}, ${1}, и т.д. for i := 0; i < 100; i++ { // Максимум 100 плейсхолдеров placeholder := fmt.Sprintf("${%d}", i) if rewardStr, ok := rewardStrings[i]; ok { result = strings.ReplaceAll(result, placeholder, rewardStr) } } // Затем заменяем $0, $1, и т.д. (экранированные уже защищены маркерами) // Ищем $N, где после N не идет еще одна цифра (чтобы не заменить $10 при поиске $1) // Go regexp не поддерживает lookahead, поэтому заменяем с конца (от больших чисел к меньшим) for i := 99; i >= 0; i-- { if rewardStr, ok := rewardStrings[i]; ok { searchStr := fmt.Sprintf("$%d", i) // Ищем все вхождения с конца строки for { idx := strings.LastIndex(result, searchStr) if idx == -1 { break } // Проверяем, что после $N не идет еще одна цифра afterIdx := idx + len(searchStr) if afterIdx >= len(result) || result[afterIdx] < '0' || result[afterIdx] > '9' { // Можно заменить result = result[:idx] + rewardStr + result[afterIdx:] } else { // После $N идет еще цифра (например, $10), пропускаем break } } } } // Восстанавливаем экранированные доллары из временных маркеров for marker, escaped := range escapedMarkers { result = strings.ReplaceAll(result, marker, escaped) } return result } // Подставляем в reward_message основной задачи var mainTaskMessage string if task.RewardMessage != nil && *task.RewardMessage != "" { mainTaskMessage = replaceRewardPlaceholders(*task.RewardMessage, rewardStrings) } else { // Если reward_message пустой, используем имя задачи mainTaskMessage = task.Name } // Получаем выбранные подзадачи (только с непустым reward_message и deleted = FALSE) subtaskMessages := make([]string, 0) if len(req.ChildrenTaskIDs) > 0 { placeholders := make([]string, len(req.ChildrenTaskIDs)) args := make([]interface{}, len(req.ChildrenTaskIDs)+1) args[0] = taskID for i, id := range req.ChildrenTaskIDs { placeholders[i] = fmt.Sprintf("$%d", i+2) args[i+1] = id } query := fmt.Sprintf(` SELECT id, name, reward_message, progression_base FROM tasks WHERE parent_task_id = $1 AND id IN (%s) AND deleted = FALSE `, strings.Join(placeholders, ",")) subtaskRows, err := a.DB.Query(query, args...) if err != nil { log.Printf("Error querying subtasks: %v", err) } else { defer subtaskRows.Close() for subtaskRows.Next() { var subtaskID int var subtaskName string var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 err := subtaskRows.Scan(&subtaskID, &subtaskName, &subtaskRewardMessage, &subtaskProgressionBase) if err != nil { log.Printf("Error scanning subtask: %v", err) continue } // Пропускаем подзадачи с пустым reward_message if !subtaskRewardMessage.Valid || subtaskRewardMessage.String == "" { continue } // Получаем награды подзадачи subtaskRewardRows, err := a.DB.Query(` SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = $1 ORDER BY rc.position `, subtaskID) if err != nil { log.Printf("Error querying subtask rewards: %v", err) continue } subtaskRewards := make([]Reward, 0) for subtaskRewardRows.Next() { var reward Reward err := subtaskRewardRows.Scan(&reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning subtask reward: %v", err) continue } subtaskRewards = append(subtaskRewards, reward) } subtaskRewardRows.Close() // Вычисляем score для наград подзадачи subtaskRewardStrings := make(map[int]string) for _, reward := range subtaskRewards { var score float64 if reward.UseProgression && subtaskProgressionBase.Valid && req.Value != nil { score = (*req.Value / subtaskProgressionBase.Float64) * reward.Value } else if reward.UseProgression && progressionBase.Valid && req.Value != nil { // Если у подзадачи нет progression_base, используем основной score = (*req.Value / progressionBase.Float64) * reward.Value } else { score = reward.Value } var rewardStr string if score >= 0 { rewardStr = fmt.Sprintf("**%s+%.4g**", reward.ProjectName, score) } else { rewardStr = fmt.Sprintf("**%s-%.4g**", reward.ProjectName, math.Abs(score)) } subtaskRewardStrings[reward.Position] = rewardStr } // Подставляем в reward_message подзадачи subtaskMessage := replaceRewardPlaceholders(subtaskRewardMessage.String, subtaskRewardStrings) subtaskMessages = append(subtaskMessages, subtaskMessage) } } } // Формируем итоговое сообщение var finalMessage strings.Builder finalMessage.WriteString(mainTaskMessage) for _, subtaskMsg := range subtaskMessages { finalMessage.WriteString("\n + ") finalMessage.WriteString(subtaskMsg) } // Отправляем сообщение через processMessage userIDPtr := &userID _, err = a.processMessage(finalMessage.String(), userIDPtr) if err != nil { // Логируем ошибку, но не откатываем транзакцию log.Printf("Error sending message to Telegram: %v", err) } // Обновляем completed и last_completed_at для основной задачи // Если repetition_date установлен, вычисляем next_show_at // Если repetition_period не установлен и repetition_date не установлен, помечаем задачу как удаленную // Если repetition_period = "0 day" (или любое значение с 0), не обновляем last_completed_at // Проверяем наличие repetition_date (используем COALESCE, поэтому пустая строка означает отсутствие) hasRepetitionDate := repetitionDate.Valid && strings.TrimSpace(repetitionDate.String) != "" if hasRepetitionDate { // Есть repetition_date - вычисляем следующую дату показа nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now()) if nextShowAt != nil { _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW(), next_show_at = $2 WHERE id = $1 `, taskID, nextShowAt) } else { // Если не удалось вычислить дату, обновляем как обычно _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL WHERE id = $1 `, taskID) } } else if repetitionPeriod.Valid { // Проверяем, является ли период нулевым (начинается с "0 ") periodStr := strings.TrimSpace(repetitionPeriod.String) isZeroPeriod := strings.HasPrefix(periodStr, "0 ") || periodStr == "0" if isZeroPeriod { // Период = 0: обновляем только счетчик, но не last_completed_at // Задача никогда не будет переноситься в выполненные _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, next_show_at = NULL WHERE id = $1 `, taskID) } else { // Обычный период: обновляем счетчик и last_completed_at, вычисляем next_show_at // next_show_at = last_completed_at + repetition_period now := time.Now() log.Printf("Calculating next_show_at for task %d: repetition_period='%s', fromDate=%v", taskID, repetitionPeriod.String, now) nextShowAt := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, now) if nextShowAt != nil { log.Printf("Calculated next_show_at for task %d: %v", taskID, *nextShowAt) _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW(), next_show_at = $2 WHERE id = $1 `, taskID, nextShowAt) } else { log.Printf("Failed to calculate next_show_at for task %d: repetition_period='%s' returned nil", taskID, repetitionPeriod.String) // Если не удалось вычислить дату, обновляем как обычно _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL WHERE id = $1 `, taskID) } } } else { _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL, deleted = TRUE WHERE id = $1 `, taskID) } if err != nil { log.Printf("Error updating task completion: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating task completion: %v", err), http.StatusInternalServerError) return } // Обновляем выбранные подзадачи if len(req.ChildrenTaskIDs) > 0 { placeholders := make([]string, len(req.ChildrenTaskIDs)) args := make([]interface{}, len(req.ChildrenTaskIDs)) for i, id := range req.ChildrenTaskIDs { placeholders[i] = fmt.Sprintf("$%d", i+1) args[i] = id } query := fmt.Sprintf(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW() WHERE id IN (%s) AND deleted = FALSE `, strings.Join(placeholders, ",")) _, err = a.DB.Exec(query, args...) if err != nil { log.Printf("Error updating subtasks completion: %v", err) // Не возвращаем ошибку, основная задача уже обновлена } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Task completed successfully", }) } // completeAndDeleteTaskHandler выполняет задачу и затем удаляет её func (a *App) completeAndDeleteTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } // Сначала выполняем задачу (используем ту же логику, что и в completeTaskHandler) // Создаем временный запрос для выполнения задачи var req CompleteTaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding complete task request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Получаем задачу и проверяем владельца var task Task var rewardMessage sql.NullString var progressionBase sql.NullFloat64 var repetitionPeriod sql.NullString var repetitionDate sql.NullString var ownerID int err = a.DB.QueryRow(` SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id FROM tasks WHERE id = $1 AND deleted = FALSE `, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error querying task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying task: %v", err), http.StatusInternalServerError) return } if ownerID != userID { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } // Валидация: если progression_base != null, то value обязателен if progressionBase.Valid && req.Value == nil { sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest) return } if rewardMessage.Valid { task.RewardMessage = &rewardMessage.String } if progressionBase.Valid { task.ProgressionBase = &progressionBase.Float64 } // Получаем награды основной задачи rewardRows, err := a.DB.Query(` SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = $1 ORDER BY rc.position `, taskID) if err != nil { log.Printf("Error querying rewards: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying rewards: %v", err), http.StatusInternalServerError) return } defer rewardRows.Close() rewards := make([]Reward, 0) for rewardRows.Next() { var reward Reward err := rewardRows.Scan(&reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning reward: %v", err) continue } rewards = append(rewards, reward) } // Вычисляем score для каждой награды и формируем строки для подстановки rewardStrings := make(map[int]string) for _, reward := range rewards { var score float64 if reward.UseProgression && progressionBase.Valid && req.Value != nil { score = (*req.Value / progressionBase.Float64) * reward.Value } else { score = reward.Value } var rewardStr string if score >= 0 { rewardStr = fmt.Sprintf("**%s+%.4g**", reward.ProjectName, score) } else { rewardStr = fmt.Sprintf("**%s-%.4g**", reward.ProjectName, math.Abs(score)) } rewardStrings[reward.Position] = rewardStr } // Функция для замены плейсхолдеров в сообщении награды replaceRewardPlaceholders := func(message string, rewardStrings map[int]string) string { result := message escapedMarkers := make(map[string]string) for i := 0; i < 100; i++ { escaped := fmt.Sprintf(`\$%d`, i) marker := fmt.Sprintf(`__ESCAPED_DOLLAR_%d__`, i) if strings.Contains(result, escaped) { escapedMarkers[marker] = escaped result = strings.ReplaceAll(result, escaped, marker) } } for i := 0; i < 100; i++ { placeholder := fmt.Sprintf("${%d}", i) if rewardStr, ok := rewardStrings[i]; ok { result = strings.ReplaceAll(result, placeholder, rewardStr) } } for i := 99; i >= 0; i-- { if rewardStr, ok := rewardStrings[i]; ok { searchStr := fmt.Sprintf("$%d", i) for { idx := strings.LastIndex(result, searchStr) if idx == -1 { break } afterIdx := idx + len(searchStr) if afterIdx >= len(result) || result[afterIdx] < '0' || result[afterIdx] > '9' { result = result[:idx] + rewardStr + result[afterIdx:] } else { break } } } } for marker, escaped := range escapedMarkers { result = strings.ReplaceAll(result, marker, escaped) } return result } // Подставляем в reward_message основной задачи var mainTaskMessage string if task.RewardMessage != nil && *task.RewardMessage != "" { mainTaskMessage = replaceRewardPlaceholders(*task.RewardMessage, rewardStrings) } else { mainTaskMessage = task.Name } // Получаем выбранные подзадачи subtaskMessages := make([]string, 0) if len(req.ChildrenTaskIDs) > 0 { placeholders := make([]string, len(req.ChildrenTaskIDs)) args := make([]interface{}, len(req.ChildrenTaskIDs)+1) args[0] = taskID for i, id := range req.ChildrenTaskIDs { placeholders[i] = fmt.Sprintf("$%d", i+2) args[i+1] = id } query := fmt.Sprintf(` SELECT id, name, reward_message, progression_base FROM tasks WHERE parent_task_id = $1 AND id IN (%s) AND deleted = FALSE `, strings.Join(placeholders, ",")) subtaskRows, err := a.DB.Query(query, args...) if err != nil { log.Printf("Error querying subtasks: %v", err) } else { defer subtaskRows.Close() for subtaskRows.Next() { var subtaskID int var subtaskName string var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 err := subtaskRows.Scan(&subtaskID, &subtaskName, &subtaskRewardMessage, &subtaskProgressionBase) if err != nil { log.Printf("Error scanning subtask: %v", err) continue } if !subtaskRewardMessage.Valid || subtaskRewardMessage.String == "" { continue } subtaskRewardRows, err := a.DB.Query(` SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = $1 ORDER BY rc.position `, subtaskID) if err != nil { log.Printf("Error querying subtask rewards: %v", err) continue } subtaskRewards := make([]Reward, 0) for subtaskRewardRows.Next() { var reward Reward err := subtaskRewardRows.Scan(&reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning subtask reward: %v", err) continue } subtaskRewards = append(subtaskRewards, reward) } subtaskRewardRows.Close() subtaskRewardStrings := make(map[int]string) for _, reward := range subtaskRewards { var score float64 if reward.UseProgression && subtaskProgressionBase.Valid && req.Value != nil { score = (*req.Value / subtaskProgressionBase.Float64) * reward.Value } else if reward.UseProgression && progressionBase.Valid && req.Value != nil { score = (*req.Value / progressionBase.Float64) * reward.Value } else { score = reward.Value } var rewardStr string if score >= 0 { rewardStr = fmt.Sprintf("**%s+%.4g**", reward.ProjectName, score) } else { rewardStr = fmt.Sprintf("**%s-%.4g**", reward.ProjectName, math.Abs(score)) } subtaskRewardStrings[reward.Position] = rewardStr } subtaskMessage := replaceRewardPlaceholders(subtaskRewardMessage.String, subtaskRewardStrings) subtaskMessages = append(subtaskMessages, subtaskMessage) } } } // Формируем итоговое сообщение var finalMessage strings.Builder finalMessage.WriteString(mainTaskMessage) for _, subtaskMsg := range subtaskMessages { finalMessage.WriteString("\n + ") finalMessage.WriteString(subtaskMsg) } // Отправляем сообщение через processMessage userIDPtr := &userID _, err = a.processMessage(finalMessage.String(), userIDPtr) if err != nil { log.Printf("Error sending message to Telegram: %v", err) } // Обновляем выбранные подзадачи if len(req.ChildrenTaskIDs) > 0 { placeholders := make([]string, len(req.ChildrenTaskIDs)) args := make([]interface{}, len(req.ChildrenTaskIDs)) for i, id := range req.ChildrenTaskIDs { placeholders[i] = fmt.Sprintf("$%d", i+1) args[i] = id } query := fmt.Sprintf(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW() WHERE id IN (%s) AND deleted = FALSE `, strings.Join(placeholders, ",")) _, err = a.DB.Exec(query, args...) if err != nil { log.Printf("Error updating subtasks completion: %v", err) } } // Помечаем задачу как удаленную _, err = a.DB.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1 AND user_id = $2", taskID, userID) if err != nil { log.Printf("Error deleting task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting task: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Task completed and deleted successfully", }) } // postponeTaskHandler переносит задачу на указанную дату func (a *App) postponeTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } var req PostponeTaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding postpone task request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Проверяем владельца var ownerID int err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking task ownership: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) return } // Если NextShowAt == nil, устанавливаем next_show_at в NULL // Иначе парсим дату и устанавливаем значение var nextShowAtValue interface{} if req.NextShowAt == nil || *req.NextShowAt == "" { nextShowAtValue = nil } else { nextShowAt, err := time.Parse(time.RFC3339, *req.NextShowAt) if err != nil { log.Printf("Error parsing next_show_at: %v", err) sendErrorWithCORS(w, "Invalid date format. Use RFC3339 format", http.StatusBadRequest) return } nextShowAtValue = nextShowAt } // Обновляем next_show_at _, err = a.DB.Exec(` UPDATE tasks SET next_show_at = $1 WHERE id = $2 AND user_id = $3 `, nextShowAtValue, taskID, userID) if err != nil { log.Printf("Error updating next_show_at: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating next_show_at: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Task postponed successfully", }) } // todoistDisconnectHandler отключает интеграцию Todoist func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } _, err := a.DB.Exec(` DELETE FROM todoist_integrations WHERE user_id = $1 `, userID) if err != nil { log.Printf("Todoist disconnect: DB error: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Failed to disconnect: %v", err), http.StatusInternalServerError) return } log.Printf("Todoist disconnected for user_id=%d", userID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Todoist disconnected", }) }