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/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"` } // ============================================ // 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(15 * time.Minute)), 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, time.Now().Add(7*24*time.Hour)) 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: 900, // 15 minutes 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, time.Now().Add(7*24*time.Hour)) 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: 900, 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 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 > 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, time.Now().Add(7*24*time.Hour)) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(TokenResponse{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: 900, 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: (failure - success) >= 5, sorted by (failure - success) DESC, then last_success_at ASC (NULL first) // 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 + ` AND (COALESCE(p.failure, 0) - COALESCE(p.success, 0)) >= 5 ` + group2Exclude + ` ORDER BY (COALESCE(p.failure, 0) - COALESCE(p.success, 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*2) // Get more to ensure uniqueness 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() && len(group2Words) < group2Count { 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, COALESCE(wg.priority, p.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) } // Находим среднее внутри каждой группы groupAverages := make([]float64, 0) for priorityVal, scores := range groups { if len(scores) > 0 { 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 / math.Floor(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 = append(groupAverages, avg) } } // Находим среднее между всеми группами var total *float64 if len(groupAverages) > 0 { sum := 0.0 for _, avg := range groupAverages { sum += avg } overallProgress := sum / float64(len(groupAverages)) overallProgressRounded := roundToFourDecimals(overallProgress) total = &overallProgressRounded } 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"} 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 a.DB.Exec("DELETE FROM refresh_tokens WHERE 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) } 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, COALESCE(wg.priority, p.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) } // Находим среднее внутри каждой группы groupAverages := make([]float64, 0) for priorityVal, scores := range groups { if len(scores) > 0 { 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 / math.Floor(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 = append(groupAverages, avg) } } // Находим среднее между всеми группами var total *float64 if len(groupAverages) > 0 { sum := 0.0 for _, avg := range groupAverages { sum += avg } overallProgress := sum / float64(len(groupAverages)) overallProgressRounded := roundToFourDecimals(overallProgress) total = &overallProgressRounded } 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, COALESCE(wg.priority, p.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 } var extraBonusLimit float64 = 20 if priorityVal == 1 { extraBonusLimit = 50 } else if priorityVal == 2 { extraBonusLimit = 35 } var calculatedScore float64 if minGoalScoreVal > 0 { percentage := (totalScore / minGoalScoreVal) * 100.0 if maxGoalScoreVal > 0 { if totalScore >= maxGoalScoreVal { calculatedScore = 100.0 + math.Min(extraBonusLimit, ((totalScore-maxGoalScoreVal)/maxGoalScoreVal)*100.0) } else { calculatedScore = percentage } } else { calculatedScore = math.Min(100.0+extraBonusLimit, percentage) } } else { calculatedScore = 0.0 } project.CalculatedScore = roundToFourDecimals(calculatedScore) projects = append(projects, project) if priorityVal > 0 { groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) } else { groups[0] = append(groups[0], project.CalculatedScore) } } // Расчет средних по группам groupAverages := make([]float64, 0) for priorityVal, scores := range groups { if len(scores) > 0 { var avg float64 if priorityVal == 1 || priorityVal == 2 { sum := 0.0 for _, score := range scores { sum += score } avg = sum / float64(len(scores)) } else { projectCount := float64(len(scores)) multiplier := 100.0 / math.Floor(projectCount * 0.8) sum := 0.0 for _, score := range scores { scoreAsDecimal := score / 100.0 sum += scoreAsDecimal * multiplier } avg = math.Min(120.0, sum) } groupAverages = append(groupAverages, avg) } } var total *float64 if len(groupAverages) > 0 { sum := 0.0 for _, avg := range groupAverages { sum += avg } overallProgress := sum / float64(len(groupAverages)) overallProgressRounded := roundToFourDecimals(overallProgress) total = &overallProgressRounded } 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) // Планировщик будет работать в фоновом режиме } func main() { // Загружаем переменные окружения из .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("/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") // 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 } // 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"` } 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) 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) // Читаем тело запроса для логирования 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 (если задан в переменных окружения) todoistWebhookSecret := getEnv("TODOIST_WEBHOOK_SECRET", "") log.Printf("Webhook secret check: configured=%v", todoistWebhookSecret != "") if todoistWebhookSecret != "" { providedSecret := r.Header.Get("X-Todoist-Webhook-Secret") log.Printf("Provided secret in header: %v (length: %d)", providedSecret != "", len(providedSecret)) if providedSecret != todoistWebhookSecret { log.Printf("Invalid Todoist webhook secret provided (expected length: %d, provided length: %d)", len(todoistWebhookSecret), len(providedSecret)) 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") } // Парсим 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 eventDataJSON, err := json.MarshalIndent(webhook.EventData, " ", " "); err == nil { log.Printf(" EventData content:\n%s", string(eventDataJSON)) } else { log.Printf(" EventData (marshal error): %v", err) } // Проверяем, что это событие закрытия задачи 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 } // Извлекаем 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) } // generateOAuthState генерирует JWT state для OAuth func generateOAuthState(userID int, jwtSecret string) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "user_id": userID, "type": "todoist_oauth", "exp": time.Now().Add(24 * time.Hour).Unix(), // 1 день }) return token.SignedString([]byte(jwtSecret)) } // validateOAuthState проверяет и извлекает user_id из JWT state func validateOAuthState(stateString string, jwtSecret string) (int, error) { token, err := jwt.Parse(stateString, func(token *jwt.Token) (interface{}, error) { return []byte(jwtSecret), nil }) if err != nil { return 0, err } claims, ok := token.Claims.(jwt.MapClaims) if !ok || !token.Valid { return 0, fmt.Errorf("invalid token") } if claims["type"] != "todoist_oauth" { return 0, fmt.Errorf("wrong token type") } userID := int(claims["user_id"].(float64)) return 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 } req, err := http.NewRequest("POST", "https://api.todoist.com/sync/v9/sync", strings.NewReader("sync_token=*&resource_types=[\"user\"]")) if err != nil { 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") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return userInfo, fmt.Errorf("failed to get user info: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return userInfo, fmt.Errorf("get user info failed: %s", string(body)) } var result struct { User struct { ID int64 `json:"id"` Email string `json:"email"` } `json:"user"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return userInfo, fmt.Errorf("failed to decode user info: %w", err) } userInfo.ID = result.User.ID userInfo.Email = result.User.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", "") jwtSecret := getEnv("JWT_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 } if jwtSecret == "" { sendErrorWithCORS(w, "JWT_SECRET must be configured", http.StatusInternalServerError) return } redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback" state, err := generateOAuthState(userID, 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: redirecting user_id=%d to Todoist", userID) http.Redirect(w, r, authURL, http.StatusTemporaryRedirect) } // 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" jwtSecret := getEnv("JWT_SECRET", "") clientID := getEnv("TODOIST_CLIENT_ID", "") clientSecret := getEnv("TODOIST_CLIENT_SECRET", "") baseURL := getEnv("WEBHOOK_BASE_URL", "") if jwtSecret == "" || 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, 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, }) } // 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", }) }