package main import ( "bytes" "context" "crypto/rand" "database/sql" "encoding/base64" "encoding/json" "fmt" "io" "log" "math" "net/http" "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 TelegramMessage struct { Text string `json:"text"` Entities []TelegramEntity `json:"entities"` Chat TelegramChat `json:"chat"` } 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 telegramChatID int64 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 { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } tx, err := a.DB.Begin() if err != nil { 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 AND id = 0 UNION ALL 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 err = tx.QueryRow(` INSERT INTO dictionaries (name, user_id) VALUES ('Все слова', $1) RETURNING id `, userID).Scan(&defaultDictID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } } stmt, err := tx.Prepare(` INSERT INTO words (name, translation, description, dictionary_id) VALUES ($1, $2, $3, COALESCE($4, $5)) RETURNING id `) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() var addedCount int for _, wordReq := range req.Words { var id int dictionaryID := defaultDictID if wordReq.DictionaryID != nil { dictionaryID = *wordReq.DictionaryID } err := stmt.QueryRow(wordReq.Name, wordReq.Translation, wordReq.Description, wordReq.DictionaryID, dictionaryID).Scan(&id) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } addedCount++ } if err := tx.Commit(); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } 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 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") // Clean up expired refresh tokens a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at < NOW()") 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 } // 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 получает данные, форматирует и отправляет отчет в Telegram func (a *App) sendDailyReport() error { log.Printf("Scheduled task: Sending daily report") // Получаем данные data, err := a.getWeeklyStatsData() if err != nil { log.Printf("Error getting weekly stats data: %v", err) return fmt.Errorf("error getting weekly stats data: %w", err) } // Форматируем сообщение message := a.formatDailyReport(data) if message == "" { log.Println("No data to send in daily report") return nil } // Отправляем сообщение в Telegram (без попытки разбирать на nodes) a.sendTelegramMessage(message) 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, // Больше не используем глобальный bot telegramChatID: 0, // Больше не используем глобальный chat_id jwtSecret: []byte(jwtSecret), } // Пытаемся настроить webhook автоматически при старте для всех пользователей с bot_token webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") if webhookBaseURL != "" { log.Printf("Setting up Telegram webhooks for all users at startup...") rows, err := app.DB.Query(` SELECT user_id, bot_token, webhook_token FROM telegram_integrations WHERE bot_token IS NOT NULL AND bot_token != '' AND webhook_token IS NOT NULL AND webhook_token != '' AND user_id IS NOT NULL `) if err != nil { log.Printf("Warning: Failed to query telegram integrations at startup: %v", err) } else { defer rows.Close() configuredCount := 0 for rows.Next() { var userID int var botToken, webhookToken string if err := rows.Scan(&userID, &botToken, &webhookToken); err != nil { log.Printf("Warning: Failed to scan telegram integration: %v", err) continue } webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram/" + webhookToken log.Printf("Setting up Telegram webhook for user_id=%d: URL=%s", userID, webhookURL) if err := setupTelegramWebhook(botToken, webhookURL); err != nil { log.Printf("Warning: Failed to setup Telegram webhook for user_id=%d: %v", userID, err) } else { log.Printf("SUCCESS: Telegram webhook configured for user_id=%d: %s", userID, webhookURL) configuredCount++ } } if configuredCount > 0 { log.Printf("Telegram webhooks configured for %d user(s) at startup", configuredCount) } else { log.Printf("No Telegram integrations found with bot_token and webhook_token. Webhooks will be configured when users save bot tokens.") } } } else { log.Printf("WEBHOOK_BASE_URL not set. Webhook will be configured when user saves bot token.") } // Инициализируем БД для 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/{token}", app.todoistWebhookHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/telegram/{token}", 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") protected.HandleFunc("/api/integrations/todoist/webhook-url", app.getTodoistWebhookURLHandler).Methods("GET", "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, _ := io.ReadAll(resp.Body) 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.NewDecoder(resp.Body).Decode(&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"` ChatID *string `json:"chat_id"` BotToken *string `json:"bot_token"` WebhookToken *string `json:"webhook_token"` } // getTelegramIntegration получает telegram интеграцию из БД // getTelegramIntegrationForUser gets telegram integration for specific user func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, error) { var integration TelegramIntegration var chatID, botToken, webhookToken sql.NullString err := a.DB.QueryRow(` SELECT id, chat_id, bot_token, webhook_token FROM telegram_integrations WHERE user_id = $1 ORDER BY id DESC LIMIT 1 `, userID).Scan(&integration.ID, &chatID, &botToken, &webhookToken) if err == sql.ErrNoRows { // Если записи нет, создаем новую для этого пользователя с webhook токеном webhookToken, err := generateWebhookToken() if err != nil { return nil, fmt.Errorf("failed to generate webhook token: %w", err) } err = a.DB.QueryRow(` INSERT INTO telegram_integrations (chat_id, bot_token, user_id, webhook_token) VALUES (NULL, NULL, $1, $2) RETURNING id `, userID, webhookToken).Scan(&integration.ID) if err != nil { return nil, fmt.Errorf("failed to create telegram integration: %w", err) } integration.WebhookToken = &webhookToken return &integration, nil } else if err != nil { return nil, fmt.Errorf("failed to get telegram integration: %w", err) } if chatID.Valid { integration.ChatID = &chatID.String } if botToken.Valid { integration.BotToken = &botToken.String } if webhookToken.Valid { integration.WebhookToken = &webhookToken.String } else { // Если токена нет, генерируем его newToken, err := generateWebhookToken() if err != nil { return nil, fmt.Errorf("failed to generate webhook token: %w", err) } _, err = a.DB.Exec(` UPDATE telegram_integrations SET webhook_token = $1 WHERE id = $2 `, newToken, integration.ID) if err != nil { return nil, fmt.Errorf("failed to update webhook token: %w", err) } integration.WebhookToken = &newToken } return &integration, nil } func (a *App) getTelegramIntegration() (*TelegramIntegration, error) { var integration TelegramIntegration var chatID, botToken sql.NullString err := a.DB.QueryRow(` SELECT id, chat_id, bot_token FROM telegram_integrations ORDER BY id DESC LIMIT 1 `).Scan(&integration.ID, &chatID, &botToken) if err == sql.ErrNoRows { // Если записи нет, создаем новую _, err = a.DB.Exec(` INSERT INTO telegram_integrations (chat_id, bot_token) VALUES (NULL, NULL) `) if err != nil { return nil, fmt.Errorf("failed to create telegram integration: %w", err) } // Повторно получаем созданную запись err = a.DB.QueryRow(` SELECT id, chat_id, bot_token FROM telegram_integrations ORDER BY id DESC LIMIT 1 `).Scan(&integration.ID, &chatID, &botToken) if err != nil { return nil, fmt.Errorf("failed to get created telegram integration: %w", err) } } else if err != nil { return nil, fmt.Errorf("failed to get telegram integration: %w", err) } if chatID.Valid { integration.ChatID = &chatID.String } if botToken.Valid { integration.BotToken = &botToken.String } return &integration, nil } // saveTelegramBotToken сохраняет bot token в БД func (a *App) saveTelegramBotToken(botToken string) error { // Проверяем, есть ли уже запись integration, err := a.getTelegramIntegration() if err != nil { // Если записи нет, создаем новую _, err = a.DB.Exec(` INSERT INTO telegram_integrations (bot_token, chat_id) VALUES ($1, NULL) `, botToken) if err != nil { return fmt.Errorf("failed to create telegram bot token: %w", err) } } else { // Обновляем существующую запись _, err = a.DB.Exec(` UPDATE telegram_integrations SET bot_token = $1 WHERE id = $2 `, botToken, integration.ID) if err != nil { return fmt.Errorf("failed to update telegram bot token: %w", err) } } return nil } func (a *App) saveTelegramBotTokenForUser(botToken string, userID int) error { // Проверяем, есть ли уже запись для этого пользователя integration, err := a.getTelegramIntegrationForUser(userID) if err != nil { // Если записи нет, создаем новую с webhook токеном webhookToken, err := generateWebhookToken() if err != nil { return fmt.Errorf("failed to generate webhook token: %w", err) } _, err = a.DB.Exec(` INSERT INTO telegram_integrations (bot_token, chat_id, user_id, webhook_token) VALUES ($1, NULL, $2, $3) `, botToken, userID, webhookToken) if err != nil { return fmt.Errorf("failed to create telegram bot token: %w", err) } } else { // Обновляем существующую запись _, err = a.DB.Exec(` UPDATE telegram_integrations SET bot_token = $1 WHERE id = $2 AND user_id = $3 `, botToken, integration.ID, userID) if err != nil { return fmt.Errorf("failed to update telegram bot token: %w", err) } // Убеждаемся, что webhook_token есть if integration.WebhookToken == nil || *integration.WebhookToken == "" { webhookToken, err := generateWebhookToken() if err != nil { return fmt.Errorf("failed to generate webhook token: %w", err) } _, err = a.DB.Exec(` UPDATE telegram_integrations SET webhook_token = $1 WHERE id = $2 `, webhookToken, integration.ID) if err != nil { return fmt.Errorf("failed to update webhook token: %w", err) } } } return nil } // saveTelegramChatID сохраняет chat_id в БД func (a *App) saveTelegramChatID(chatID string) error { // Получаем текущую интеграцию integration, err := a.getTelegramIntegration() if err != nil { return fmt.Errorf("failed to get telegram integration: %w", err) } _, err = a.DB.Exec(` UPDATE telegram_integrations SET chat_id = $1 WHERE id = $2 `, chatID, integration.ID) if err != nil { return fmt.Errorf("failed to save telegram chat_id: %w", err) } return nil } // getTelegramBotAndChatID получает bot token и chat_id из БД и создает bot API func (a *App) getTelegramBotAndChatID() (*tgbotapi.BotAPI, int64, error) { integration, err := a.getTelegramIntegration() if err != nil { return nil, 0, err } if integration.BotToken == nil || *integration.BotToken == "" { return nil, 0, nil // Bot token не настроен } bot, err := tgbotapi.NewBotAPI(*integration.BotToken) if err != nil { return nil, 0, fmt.Errorf("failed to initialize Telegram bot: %w", err) } var chatID int64 = 0 if integration.ChatID != nil && *integration.ChatID != "" { chatID, err = strconv.ParseInt(*integration.ChatID, 10, 64) if err != nil { log.Printf("Warning: Invalid chat_id format in database: %v", err) chatID = 0 } } return bot, chatID, nil } func (a *App) sendTelegramMessage(text string) { log.Printf("sendTelegramMessage called with text length: %d", len(text)) // Получаем bot и chat_id из БД bot, chatID, err := a.getTelegramBotAndChatID() if err != nil { log.Printf("WARNING: Failed to get Telegram bot from database: %v, skipping message send", err) return } if bot == nil || chatID == 0 { // Telegram не настроен, пропускаем отправку log.Printf("WARNING: Telegram bot not configured (bot=%v, chatID=%d), skipping message send", bot != nil, chatID) return } // Конвертируем **текст** в *текст* для Markdown (Legacy) // Markdown (Legacy) использует одинарную звездочку для жирного текста // Используем регулярное выражение для замены только парных ** telegramText := regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "*$1*") log.Printf("Sending Telegram message (converted text length: %d): %s", len(telegramText), telegramText) msg := tgbotapi.NewMessage(chatID, telegramText) msg.ParseMode = "Markdown" // Markdown (Legacy) format _, err = bot.Send(msg) if err != nil { log.Printf("ERROR sending Telegram message: %v", err) } else { log.Printf("Telegram message sent successfully to chat ID %d", chatID) } } // 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 { a.sendTelegramMessage(rawText) } 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 { _, err := tx.Exec(` INSERT INTO projects (name, deleted, user_id) VALUES ($1, FALSE, $2) ON CONFLICT ON CONSTRAINT unique_project_name DO UPDATE SET name = EXCLUDED.name, deleted = FALSE `, projectName, *userID) if err != nil { // Try without user constraint for backwards compatibility _, err = tx.Exec(` INSERT INTO projects (name, deleted, user_id) VALUES ($1, FALSE, $2) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name, deleted = FALSE, user_id = COALESCE(projects.user_id, EXCLUDED.user_id) `, projectName, *userID) if err != nil { return fmt.Errorf("failed to upsert project %s: %w", projectName, err) } } } else { _, err := tx.Exec(` INSERT INTO projects (name, deleted) VALUES ($1, FALSE) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name, deleted = FALSE `, projectName) if err != nil { return fmt.Errorf("failed to upsert 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 { _, err := tx.Exec(` INSERT INTO nodes (project_id, entry_id, score) SELECT p.id, $1, $2 FROM projects p WHERE p.name = $3 AND p.deleted = FALSE `, entryID, node.Score, node.Project) 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 } // sendWeeklyGoalsTelegramMessage получает зафиксированные цели и отправляет их в Telegram func (a *App) sendWeeklyGoalsTelegramMessage() 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 ORDER BY p.name ` rows, err := a.DB.Query(selectQuery) if err != nil { return 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 { // Если maxGoalScore не установлен (NULL), используем NaN для корректной проверки в форматировании goal.MaxGoalScore = math.NaN() } goals = append(goals, goal) } // Форматируем сообщение message := a.formatWeeklyGoalsMessage(goals) if message == "" { log.Println("No goals to send in Telegram message") return nil } // Отправляем сообщение в Telegram a.sendTelegramMessage(message) 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) // Извлекаем токен из URL vars := mux.Vars(r) token := vars["token"] log.Printf("Extracted token from URL: '%s'", token) if token == "" { log.Printf("Todoist webhook: missing token in URL") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Missing webhook token", "message": "Token required in URL", }) return } // Находим пользователя по токену из telegram_integrations (используем тот же механизм) var userID int err := a.DB.QueryRow(` SELECT user_id FROM telegram_integrations WHERE webhook_token = $1 AND user_id IS NOT NULL LIMIT 1 `, token).Scan(&userID) if err == sql.ErrNoRows { log.Printf("Todoist webhook: invalid token: %s", token) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Invalid webhook token", "message": "Token not found", }) return } else if err != nil { log.Printf("Error finding user by webhook token: %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: token=%s, user_id=%d", token, 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)) // Создаем новый reader из прочитанных байтов для парсинга r.Body = io.NopCloser(bytes.NewBuffer(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.NewDecoder(r.Body).Decode(&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") json.NewEncoder(w).Encode(map[string]string{ "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") json.NewEncoder(w).Encode(map[string]interface{}{ "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) a.sendTelegramMessage(combinedText) 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) { log.Printf("=== Telegram Webhook Request ===") log.Printf("Method: %s", r.Method) log.Printf("URL: %s", r.URL.String()) log.Printf("Path: %s", r.URL.Path) if r.Method == "OPTIONS" { log.Printf("OPTIONS request, returning OK") setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) // Извлекаем токен из URL vars := mux.Vars(r) token := vars["token"] log.Printf("Extracted token from URL: '%s'", token) if token == "" { log.Printf("Telegram webhook: missing token in URL") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Missing webhook token", "message": "Token required in URL", }) return } // Находим пользователя по токену var userID int err := a.DB.QueryRow(` SELECT user_id FROM telegram_integrations WHERE webhook_token = $1 AND user_id IS NOT NULL LIMIT 1 `, token).Scan(&userID) if err == sql.ErrNoRows { log.Printf("Telegram webhook: invalid token: %s", token) // Возвращаем 200 OK, но логируем ошибку (не хотим, чтобы Telegram повторял запрос) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Invalid webhook token", "message": "Token not found", }) return } else if err != nil { log.Printf("Error finding user by webhook token: %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("Telegram webhook: token=%s, user_id=%d", token, userID) // Парсим webhook от Telegram var update TelegramUpdate if err := json.NewDecoder(r.Body).Decode(&update); err != nil { log.Printf("Error decoding Telegram webhook: %v", err) // Возвращаем 200 OK, чтобы Telegram не повторял запрос 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 decode webhook", }) return } // Определяем, какое сообщение использовать (message или edited_message) var message *TelegramMessage if update.Message != nil { message = update.Message log.Printf("Telegram webhook received: update_id=%d, message type=message", update.UpdateID) } else if update.EditedMessage != nil { message = update.EditedMessage log.Printf("Telegram webhook received: update_id=%d, message type=edited_message", update.UpdateID) } else { log.Printf("Telegram webhook received: update_id=%d, but no message or edited_message found", update.UpdateID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // Возвращаем 200 OK для Telegram json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "No message found in update", }) return } log.Printf("Telegram webhook: message present, chat_id=%d, user_id=%d", message.Chat.ID, userID) // Сохраняем chat_id при первом сообщении (если еще не сохранен) if message.Chat.ID != 0 { chatIDStr := strconv.FormatInt(message.Chat.ID, 10) var existingChatID sql.NullString err := a.DB.QueryRow(` SELECT chat_id FROM telegram_integrations WHERE user_id = $1 LIMIT 1 `, userID).Scan(&existingChatID) if err == nil && (!existingChatID.Valid || existingChatID.String == "") { // Сохраняем chat_id, если его еще нет _, err = a.DB.Exec(` UPDATE telegram_integrations SET chat_id = $1 WHERE user_id = $2 `, chatIDStr, userID) if err != nil { log.Printf("Warning: Failed to save chat_id: %v", err) } else { log.Printf("Successfully saved chat_id from first message: %s", chatIDStr) } } } userIDPtr := &userID // Проверяем, что есть текст в сообщении if message.Text == "" { log.Printf("Telegram webhook: no text in message") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // Возвращаем 200 OK для Telegram json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "No text in message, ignored", }) return } fullText := message.Text entities := message.Entities if entities == nil { entities = []TelegramEntity{} } log.Printf("Processing Telegram message: text='%s', entities count=%d, user_id=%d", fullText, len(entities), userID) // Обрабатываем сообщение через новую логику (с entities, без отправки обратно в Telegram) response, err := a.processTelegramMessage(fullText, entities, userIDPtr) if err != nil { log.Printf("Error processing Telegram message: %v", err) // Возвращаем 200 OK, чтобы Telegram не повторял запрос w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": err.Error(), "message": "Error processing message", }) return } log.Printf("Successfully processed Telegram message, found %d nodes", len(response.Nodes)) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "Message processed successfully", "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 интеграцию 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 } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(integration) } // TelegramIntegrationUpdateRequest представляет запрос на обновление telegram интеграции type TelegramIntegrationUpdateRequest struct { BotToken string `json:"bot_token"` } // updateTelegramIntegrationHandler обновляет bot token для telegram интеграции func (a *App) updateTelegramIntegrationHandler(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 TelegramIntegrationUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.BotToken == "" { sendErrorWithCORS(w, "bot_token is required", http.StatusBadRequest) return } if err := a.saveTelegramBotTokenForUser(req.BotToken, userID); err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to save bot token: %v", err), http.StatusInternalServerError) return } // Получаем обновленную интеграцию с webhook токеном integration, err := a.getTelegramIntegrationForUser(userID) if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to get updated integration: %v", err), http.StatusInternalServerError) return } // Настраиваем webhook автоматически при сохранении токена webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") log.Printf("Attempting to setup Telegram webhook. WEBHOOK_BASE_URL='%s'", webhookBaseURL) if webhookBaseURL != "" && integration.WebhookToken != nil && *integration.WebhookToken != "" { webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram/" + *integration.WebhookToken log.Printf("Setting up Telegram webhook: URL=%s", webhookURL) if err := setupTelegramWebhook(req.BotToken, webhookURL); err != nil { log.Printf("ERROR: Failed to setup Telegram webhook: %v", err) // Не возвращаем ошибку, так как токен уже сохранен } else { log.Printf("SUCCESS: Telegram webhook configured successfully: %s", webhookURL) } } else { log.Printf("WARNING: WEBHOOK_BASE_URL not set or webhook_token missing. Webhook will not be configured automatically.") } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(integration) } // getTodoistWebhookURLHandler возвращает URL для Todoist webhook func (a *App) getTodoistWebhookURLHandler(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 } // Получаем webhook токен для пользователя integration, err := a.getTelegramIntegrationForUser(userID) if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to get telegram integration: %v", err), http.StatusInternalServerError) return } if integration.WebhookToken == nil || *integration.WebhookToken == "" { sendErrorWithCORS(w, "Webhook token not available", http.StatusInternalServerError) return } // Получаем base URL из env baseURL := getEnv("WEBHOOK_BASE_URL", "") if baseURL == "" { sendErrorWithCORS(w, "WEBHOOK_BASE_URL not configured", http.StatusInternalServerError) return } webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist/" + *integration.WebhookToken w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "webhook_url": webhookURL, }) }