package main import ( "bytes" "database/sql" "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/gorilla/mux" "github.com/joho/godotenv" _ "github.com/lib/pq" "github.com/robfig/cron/v3" ) 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 TelegramMessage struct { Text string `json:"text"` Entities []TelegramEntity `json:"entities"` } type TelegramWebhook struct { Message TelegramMessage `json:"message"` } // TelegramUpdate - структура для Telegram webhook (обычно это Update объект) type TelegramUpdate struct { UpdateID int `json:"update_id"` Message TelegramMessage `json:"message"` } type App struct { DB *sql.DB webhookMutex sync.Mutex lastWebhookTime map[int]time.Time // config_id -> last webhook time telegramBot *tgbotapi.BotAPI telegramChatID int64 } 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") } 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" { 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 } // 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 LEFT JOIN progress p ON w.id = p.word_id WHERE ($1::INTEGER IS NULL OR w.dictionary_id = $1) ORDER BY w.id ` var rows *sql.Rows var err error if dictionaryID != nil { rows, err = a.DB.Query(query, *dictionaryID) } else { rows, err = a.DB.Query(query, nil) } if err != nil { http.Error(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 { http.Error(w, err.Error(), http.StatusInternalServerError) return } if lastSuccess.Valid { word.LastSuccess = &lastSuccess.String } if lastFailure.Valid { word.LastFailure = &lastFailure.String } words = append(words, word) } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(words) } func (a *App) addWordsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.WriteHeader(http.StatusOK) return } var req WordsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } tx, err := a.DB.Begin() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() stmt, err := tx.Prepare(` INSERT INTO words (name, translation, description, dictionary_id) VALUES ($1, $2, $3, COALESCE($4, 0)) RETURNING id `) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() var addedCount int for _, wordReq := range req.Words { var id int dictionaryID := 0 if wordReq.DictionaryID != nil { dictionaryID = *wordReq.DictionaryID } err := stmt.QueryRow(wordReq.Name, wordReq.Translation, wordReq.Description, dictionaryID).Scan(&id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } addedCount++ } 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": 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 } // 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 var wordsCount int err = a.DB.QueryRow("SELECT words_count FROM configs WHERE id = $1", configID).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 := ` FROM words w LEFT JOIN progress p ON w.id = p.word_id WHERE ` + 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 } 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", len(req.Words), req.ConfigID) tx, err := a.DB.Begin() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() stmt, err := tx.Prepare(` INSERT INTO progress (word_id, success, failure, last_success_at, last_failure_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (word_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, 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) 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" { 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 } query := ` SELECT id, name, words_count, max_cards, try_message FROM configs ORDER BY id ` rows, err := a.DB.Query(query) if err != nil { http.Error(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 { http.Error(w, err.Error(), http.StatusInternalServerError) return } if maxCards.Valid { maxCardsVal := int(maxCards.Int64) config.MaxCards = &maxCardsVal } configs = append(configs, config) } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(configs) } func (a *App) getDictionariesHandler(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 } 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 GROUP BY d.id, d.name ORDER BY d.id ` rows, err := a.DB.Query(query) if err != nil { http.Error(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 { http.Error(w, err.Error(), http.StatusInternalServerError) return } dictionaries = append(dictionaries, dict) } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(dictionaries) } func (a *App) addDictionaryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.WriteHeader(http.StatusOK) return } var req DictionaryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "Имя словаря обязательно"}) return } var id int err := a.DB.QueryRow(` INSERT INTO dictionaries (name) VALUES ($1) RETURNING id `, req.Name).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) vars := mux.Vars(r) dictionaryID := vars["id"] var req DictionaryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "Имя словаря обязательно"}) return } result, err := a.DB.Exec(` UPDATE dictionaries SET name = $1 WHERE id = $2 `, req.Name, dictionaryID) 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) vars := mux.Vars(r) dictionaryID := vars["id"] // Prevent deletion of default dictionary (id = 0) if dictionaryID == "0" { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"error": "Cannot delete default dictionary"}) 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" { 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 } // Get configs configsQuery := ` SELECT id, name, words_count, max_cards, try_message FROM configs ORDER BY id ` configsRows, err := a.DB.Query(configsQuery) 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 GROUP BY d.id, d.name ORDER BY d.id ` dictsRows, err := a.DB.Query(dictsQuery) 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" { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.WriteHeader(http.StatusOK) return } var req ConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"message": "Имя обязательно для заполнения"}) return } if req.WordsCount <= 0 { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"message": "Количество слов должно быть больше 0"}) return } tx, err := a.DB.Begin() if err != nil { http.Error(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) VALUES ($1, $2, $3, $4) RETURNING id `, req.Name, req.WordsCount, req.MaxCards, req.TryMessage).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) vars := mux.Vars(r) configID := vars["id"] var req ConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"message": "Имя обязательно для заполнения"}) return } if req.WordsCount <= 0 { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(http.StatusBadRequest) json.NewEncoder(w).Encode(map[string]string{"message": "Количество слов должно быть больше 0"}) 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) vars := mux.Vars(r) configID := vars["id"] result, err := a.DB.Exec("DELETE FROM configs WHERE id = $1", configID) 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) log.Printf("getWeeklyStatsHandler called from %s, path: %s", r.RemoteAddr, r.URL.Path) // Опционально обновляем materialized view перед запросом // Это можно сделать через query parameter ?refresh=true if r.URL.Query().Get("refresh") == "true" { _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") if err != nil { log.Printf("Warning: Failed to refresh materialized view: %v", err) // Продолжаем выполнение даже если обновление не удалось } } query := ` SELECT p.name AS project_name, -- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, wg.max_goal_score, wg.priority FROM weekly_goals wg JOIN projects p ON wg.project_id = p.id LEFT JOIN weekly_report_mv wr ON wg.project_id = wr.project_id AND wg.goal_year = wr.report_year AND wg.goal_week = wr.report_week WHERE -- Фильтруем ТОЛЬКО по целям текущего года и недели wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER ORDER BY total_score DESC ` rows, err := a.DB.Query(query) 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 maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &project.ProjectName, &project.TotalScore, &project.MinGoalScore, &maxGoalScore, &priority, ) if err != nil { log.Printf("Error scanning weekly stats row: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } 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 minGoalScore := 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 minGoalScore > 0 { baseProgress = (min(totalScore, minGoalScore) / minGoalScore) * 100.0 } // Расчет экстра прогресса var extraProgress float64 denominator := maxGoalScoreVal - minGoalScore if denominator > 0 && totalScore > minGoalScore { excess := min(totalScore, maxGoalScoreVal) - minGoalScore extraProgress = (excess / denominator) * extraBonusLimit } resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета 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 _, scores := range groups { if len(scores) > 0 { sum := 0.0 for _, score := range scores { sum += score } avg := sum / float64(len(scores)) 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) 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) } return nil } // startWeeklyGoalsScheduler запускает планировщик для автоматической фиксации целей на неделю // каждый понедельник в 6:00 утра в указанном часовом поясе func (a *App) startWeeklyGoalsScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) loc = time.UTC } else { log.Printf("Scheduler timezone set to: %s", timezoneStr) } // Создаем планировщик с указанным часовым поясом c := cron.New(cron.WithLocation(loc)) // Добавляем задачу: каждый понедельник в 6:00 утра // Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1) _, err = c.AddFunc("0 6 * * 1", func() { log.Printf("Scheduled task: Setting up weekly goals (timezone: %s)", timezoneStr) 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.Println("Weekly goals scheduler started: every Monday at 6:00 AM") // Планировщик будет работать в фоновом режиме } // getWeeklyStatsData получает данные о проектах и их целях (без HTTP обработки) func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { // Обновляем materialized view перед запросом _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") if err != nil { log.Printf("Warning: Failed to refresh materialized view: %v", err) // Продолжаем выполнение даже если обновление не удалось } query := ` SELECT p.name AS project_name, -- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, wg.max_goal_score, wg.priority FROM weekly_goals wg JOIN projects p ON wg.project_id = p.id LEFT JOIN weekly_report_mv wr ON wg.project_id = wr.project_id AND wg.goal_year = wr.report_year AND wg.goal_week = wr.report_week 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 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 maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &project.ProjectName, &project.TotalScore, &project.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 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 minGoalScore := 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 minGoalScore > 0 { baseProgress = (min(totalScore, minGoalScore) / minGoalScore) * 100.0 } // Расчет экстра прогресса var extraProgress float64 denominator := maxGoalScoreVal - minGoalScore if denominator > 0 && totalScore > minGoalScore { excess := min(totalScore, maxGoalScoreVal) - minGoalScore extraProgress = (excess / denominator) * extraBonusLimit } resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета 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 _, scores := range groups { if len(scores) > 0 { sum := 0.0 for _, score := range scores { sum += score } avg := sum / float64(len(scores)) 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 запускает планировщик для ежедневного отчета // каждый день в 11:59 в указанном часовом поясе func (a *App) startDailyReportScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) loc = time.UTC } else { log.Printf("Daily report scheduler timezone set to: %s", timezoneStr) } // Создаем планировщик с указанным часовым поясом c := cron.New(cron.WithLocation(loc)) // Добавляем задачу: каждый день в 11:59 // Cron выражение: "59 11 * * *" означает: минута=59, час=11, любой день месяца, любой месяц, любой день недели _, err = c.AddFunc("59 11 * * *", func() { log.Printf("Scheduled task: Sending daily report (timezone: %s)", timezoneStr) 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.Println("Daily report scheduler started: every day at 11:59 AM") // Планировщик будет работать в фоновом режиме } 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 бота (если токен указан) var telegramBot *tgbotapi.BotAPI var telegramChatID int64 telegramToken := getEnv("TELEGRAM_BOT_TOKEN", "") telegramChatIDStr := getEnv("TELEGRAM_CHAT_ID", "") telegramWebhookBaseURL := getEnv("TELEGRAM_WEBHOOK_BASE_URL", "") if telegramToken != "" && telegramChatIDStr != "" { bot, err := tgbotapi.NewBotAPI(telegramToken) if err != nil { log.Printf("Warning: Failed to initialize Telegram bot: %v. Telegram notifications will be disabled.", err) } else { telegramBot = bot chatID, err := strconv.ParseInt(telegramChatIDStr, 10, 64) if err != nil { log.Printf("Warning: Invalid TELEGRAM_CHAT_ID format: %v. Telegram notifications will be disabled.", err) telegramBot = nil } else { telegramChatID = chatID log.Printf("Telegram bot initialized successfully. Chat ID: %d", telegramChatID) } } } else { log.Println("Telegram bot token or chat ID not provided. Telegram notifications disabled.") } // Настраиваем webhook для Telegram (если указан base URL) if telegramToken != "" && telegramWebhookBaseURL != "" { webhookURL := strings.TrimRight(telegramWebhookBaseURL, "/") + "/webhook/telegram" if err := setupTelegramWebhook(telegramToken, webhookURL); err != nil { log.Printf("Warning: Failed to setup Telegram webhook: %v. Webhook will not be configured.", err) } else { log.Printf("Telegram webhook configured successfully: %s", webhookURL) } } else if telegramToken != "" { log.Println("TELEGRAM_WEBHOOK_BASE_URL not provided. Telegram webhook will not be configured automatically.") } app := &App{ DB: db, lastWebhookTime: make(map[int]time.Time), telegramBot: telegramBot, telegramChatID: telegramChatID, } // Инициализируем БД для 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") // Запускаем планировщик для автоматической фиксации целей на неделю app.startWeeklyGoalsScheduler() // Запускаем планировщик для ежедневного отчета в 11:59 app.startDailyReportScheduler() r := mux.NewRouter() r.HandleFunc("/api/words", app.getWordsHandler).Methods("GET", "OPTIONS") r.HandleFunc("/api/words", app.addWordsHandler).Methods("POST", "OPTIONS") r.HandleFunc("/api/test/words", app.getTestWordsHandler).Methods("GET", "OPTIONS") r.HandleFunc("/api/test/progress", app.updateTestProgressHandler).Methods("POST", "OPTIONS") r.HandleFunc("/api/configs", app.getConfigsHandler).Methods("GET", "OPTIONS") r.HandleFunc("/api/configs", app.addConfigHandler).Methods("POST", "OPTIONS") r.HandleFunc("/api/configs/{id}", app.updateConfigHandler).Methods("PUT", "OPTIONS") r.HandleFunc("/api/configs/{id}", app.deleteConfigHandler).Methods("DELETE", "OPTIONS") r.HandleFunc("/api/configs/{id}/dictionaries", app.getConfigDictionariesHandler).Methods("GET", "OPTIONS") r.HandleFunc("/api/dictionaries", app.getDictionariesHandler).Methods("GET", "OPTIONS") r.HandleFunc("/api/dictionaries", app.addDictionaryHandler).Methods("POST", "OPTIONS") r.HandleFunc("/api/dictionaries/{id}", app.updateDictionaryHandler).Methods("PUT", "OPTIONS") r.HandleFunc("/api/dictionaries/{id}", app.deleteDictionaryHandler).Methods("DELETE", "OPTIONS") r.HandleFunc("/api/test-configs-and-dictionaries", app.getTestConfigsAndDictionariesHandler).Methods("GET", "OPTIONS") r.HandleFunc("/api/weekly-stats", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") r.HandleFunc("/playlife-feed", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") r.HandleFunc("/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS") r.HandleFunc("/weekly_goals/setup", app.weeklyGoalsSetupHandler).Methods("POST", "OPTIONS") r.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS") r.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS") r.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS") r.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS") r.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") r.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS") r.HandleFunc("/admin", app.adminHandler).Methods("GET") r.HandleFunc("/admin.html", app.adminHandler).Methods("GET") r.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") port := getEnv("PORT", "8080") log.Printf("Server starting on port %s", port) log.Printf("Registered routes: /api/words (GET, POST), /api/test/words (GET), /api/test/progress (POST), /api/configs (GET, POST, PUT, DELETE), /api/dictionaries (GET, POST, PUT, DELETE), /api/test-configs-and-dictionaries (GET), /api/weekly-stats (GET), /playlife-feed (GET), /message/post (POST), /webhook/message/post (POST), /webhook/todoist (POST), /webhook/telegram (POST), /weekly_goals/setup (POST), /daily-report/trigger (POST), /projects (GET), /project/priority (POST), /d2dc349a-0d13-49b2-a8f0-1ab094bfba9b (GET), /admin (GET)") 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) 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 { return fmt.Errorf("failed to send webhook setup request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) 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 } func (a *App) sendTelegramMessage(text string) { log.Printf("sendTelegramMessage called with text length: %d", len(text)) log.Printf("Telegram bot status: bot=%v, chatID=%d", a.telegramBot != nil, a.telegramChatID) if a.telegramBot == nil || a.telegramChatID == 0 { // Telegram не настроен, пропускаем отправку log.Printf("WARNING: Telegram bot not initialized (bot=%v, chatID=%d), skipping message send", a.telegramBot != nil, a.telegramChatID) 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(a.telegramChatID, telegramText) msg.ParseMode = "Markdown" // Markdown (Legacy) format _, err := a.telegramBot.Send(msg) if err != nil { log.Printf("ERROR sending Telegram message: %v", err) } else { log.Printf("Telegram message sent successfully to chat ID %d", a.telegramChatID) } } // 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 func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity) (*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) 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) (*ProcessedEntry, error) { return a.processMessageInternal(rawText, true) } // processMessageWithoutTelegram обрабатывает текст сообщения: парсит ноды, сохраняет в БД, но НЕ отправляет в Telegram func (a *App) processMessageWithoutTelegram(rawText string) (*ProcessedEntry, error) { return a.processMessageInternal(rawText, false) } // processMessageInternal - внутренняя функция обработки сообщения // sendToTelegram определяет, нужно ли отправлять сообщение в Telegram func (a *App) processMessageInternal(rawText string, sendToTelegram bool) (*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) 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) // Парсим входящий запрос - может быть как {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) 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) 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 { _, 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 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 ( -- Считаем медиану на основе последних 12 записей из вьюхи SELECT project_id, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_score) AS median_score FROM ( SELECT project_id, total_score, -- Нумеруем недели от новых к старым ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn FROM weekly_report_mv ) sub WHERE rn <= 12 -- Берем историю за последние 12 недель активности 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, COALESCE(gm.median_score, 0) AS min_goal_score, -- Логика max_score в зависимости от приоритета CASE WHEN p.priority = 1 THEN COALESCE(gm.median_score, 0) * 1.5 WHEN p.priority = 2 THEN COALESCE(gm.median_score, 0) * 1.3 ELSE COALESCE(gm.median_score, 0) * 1.2 END + (CASE WHEN COALESCE(gm.median_score, 0) = 0 THEN 10 ELSE 0 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) query := ` SELECT id AS project_id, name AS project_name, priority FROM projects WHERE deleted = FALSE ORDER BY priority ASC NULLS LAST, project_name ` rows, err := a.DB.Query(query) 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) // Читаем тело запроса один раз 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 `, project.ID) } else { _, err = tx.Exec(` UPDATE projects SET priority = $1 WHERE id = $2 `, *project.Priority, project.ID) } 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) 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) var finalProjectID int if err == sql.ErrNoRows { // Проект не найден - создаем новый err = tx.QueryRow(` INSERT INTO projects (name, deleted) VALUES ($1, FALSE) RETURNING id `, req.NewName).Scan(&finalProjectID) if err != nil { log.Printf("Error creating new project: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating new project: %v", err), http.StatusInternalServerError) 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 } else { // Проект найден - используем его ID 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) 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 } // Начинаем транзакцию 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("RemoteAddr: %s", r.RemoteAddr) log.Printf("Headers:") for key, values := range r.Header { for _, value := range values { log.Printf(" %s: %s", key, value) } } if r.Method == "OPTIONS" { log.Printf("OPTIONS request, returning OK") setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) // Читаем тело запроса для логирования 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 } // Логируем сырое тело запроса 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)) sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) 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)) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) 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)) sendErrorWithCORS(w, "Missing 'content' or 'description' in event_data", http.StatusBadRequest) 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) log.Printf("Calling processMessageWithoutTelegram with combined text...") response, err := a.processMessageWithoutTelegram(combinedText) if err != nil { log.Printf("ERROR processing Todoist message: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) 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") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Task processed successfully", "result": response, }) } func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) // Парсим webhook от Telegram var update TelegramUpdate if err := json.NewDecoder(r.Body).Decode(&update); err != nil { log.Printf("Error decoding Telegram webhook: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Проверяем, что есть message if update.Message.Text == "" { log.Printf("Telegram webhook: no text in message") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "No text in message, ignored", }) return } fullText := update.Message.Text entities := update.Message.Entities if entities == nil { entities = []TelegramEntity{} } log.Printf("Processing Telegram message: text='%s', entities count=%d", fullText, len(entities)) // Обрабатываем сообщение через новую логику (с entities, без отправки обратно в Telegram) response, err := a.processTelegramMessage(fullText, entities) if err != nil { log.Printf("Error processing Telegram message: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) 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{}{ "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) 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 ORDER BY report_year DESC, report_week DESC, project_name ` rows, err := a.DB.Query(query) 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) }