From a611f059597e3fad70496f83662ccf9a39cd9c06 Mon Sep 17 00:00:00 2001 From: poignatov Date: Mon, 26 Jan 2026 18:45:58 +0300 Subject: [PATCH] =?UTF-8?q?4.1.0:=20=D0=9E=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B5=D0=B9=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-backend/main.go | 832 +++++++++++------- .../000004_optimize_weekly_report_mv.down.sql | 67 ++ .../000004_optimize_weekly_report_mv.up.sql | 94 ++ play-life-web/package.json | 2 +- 5 files changed, 671 insertions(+), 326 deletions(-) create mode 100644 play-life-backend/migrations/000004_optimize_weekly_report_mv.down.sql create mode 100644 play-life-backend/migrations/000004_optimize_weekly_report_mv.up.sql diff --git a/VERSION b/VERSION index d13e837..ee74734 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.0.6 +4.1.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index fdd79ed..58b5bd8 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -26,20 +26,21 @@ import ( "time" "unicode/utf16" + "image/jpeg" + "github.com/chromedp/chromedp" "github.com/disintegration/imaging" - "github.com/go-telegram-bot-api/telegram-bot-api/v5" + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "github.com/golang-jwt/jwt/v5" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/postgres" _ "github.com/golang-migrate/migrate/v4/source/file" "github.com/gorilla/mux" "github.com/joho/godotenv" - _ "github.com/lib/pq" "github.com/lib/pq" + _ "github.com/lib/pq" "github.com/robfig/cron/v3" "golang.org/x/crypto/bcrypt" - "image/jpeg" ) type Word struct { @@ -73,8 +74,8 @@ type TestProgressUpdate struct { } type TestProgressRequest struct { - Words []TestProgressUpdate `json:"words"` - ConfigID *int `json:"config_id,omitempty"` + Words []TestProgressUpdate `json:"words"` + ConfigID *int `json:"config_id,omitempty"` } type Config struct { @@ -90,9 +91,9 @@ type ConfigRequest struct { } type Dictionary struct { - ID int `json:"id"` + ID int `json:"id"` Name string `json:"name"` - WordsCount int `json:"wordsCount"` + WordsCount int `json:"wordsCount"` } type DictionaryRequest struct { @@ -100,7 +101,7 @@ type DictionaryRequest struct { } type TestConfigsAndDictionariesResponse struct { - Configs []Config `json:"configs"` + Configs []Config `json:"configs"` Dictionaries []Dictionary `json:"dictionaries"` } @@ -153,14 +154,14 @@ type WeeklyGoalSetup struct { } type Project struct { - ProjectID int `json:"project_id"` - ProjectName string `json:"project_name"` - Priority *int `json:"priority,omitempty"` + 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"` + ID int `json:"id"` + Priority *int `json:"priority"` } type ProjectPriorityRequest struct { @@ -215,34 +216,34 @@ type TelegramUpdate struct { // Task structures type Task struct { - ID int `json:"id"` - Name string `json:"name"` - Completed int `json:"completed"` - LastCompletedAt *string `json:"last_completed_at,omitempty"` - NextShowAt *string `json:"next_show_at,omitempty"` - RewardMessage *string `json:"reward_message,omitempty"` - ProgressionBase *float64 `json:"progression_base,omitempty"` - RepetitionPeriod *string `json:"repetition_period,omitempty"` - RepetitionDate *string `json:"repetition_date,omitempty"` - WishlistID *int `json:"wishlist_id,omitempty"` - ConfigID *int `json:"config_id,omitempty"` - RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями + ID int `json:"id"` + Name string `json:"name"` + Completed int `json:"completed"` + LastCompletedAt *string `json:"last_completed_at,omitempty"` + NextShowAt *string `json:"next_show_at,omitempty"` + RewardMessage *string `json:"reward_message,omitempty"` + ProgressionBase *float64 `json:"progression_base,omitempty"` + RepetitionPeriod *string `json:"repetition_period,omitempty"` + RepetitionDate *string `json:"repetition_date,omitempty"` + WishlistID *int `json:"wishlist_id,omitempty"` + ConfigID *int `json:"config_id,omitempty"` + RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) - ProjectNames []string `json:"project_names"` - SubtasksCount int `json:"subtasks_count"` - HasProgression bool `json:"has_progression"` + ProjectNames []string `json:"project_names"` + SubtasksCount int `json:"subtasks_count"` + HasProgression bool `json:"has_progression"` } type Reward struct { - ID int `json:"id"` - Position int `json:"position"` - ProjectName string `json:"project_name"` - Value float64 `json:"value"` - UseProgression bool `json:"use_progression"` + ID int `json:"id"` + Position int `json:"position"` + ProjectName string `json:"project_name"` + Value float64 `json:"value"` + UseProgression bool `json:"use_progression"` } type Subtask struct { - Task Task `json:"task"` + Task Task `json:"task"` Rewards []Reward `json:"rewards"` } @@ -253,10 +254,10 @@ type WishlistInfo struct { } type TaskDetail struct { - Task Task `json:"task"` - Rewards []Reward `json:"rewards"` - Subtasks []Subtask `json:"subtasks"` - WishlistInfo *WishlistInfo `json:"wishlist_info,omitempty"` + Task Task `json:"task"` + Rewards []Reward `json:"rewards"` + Subtasks []Subtask `json:"subtasks"` + WishlistInfo *WishlistInfo `json:"wishlist_info,omitempty"` // Test-specific fields (only present if task has config_id) WordsCount *int `json:"words_count,omitempty"` MaxCards *int `json:"max_cards,omitempty"` @@ -278,15 +279,15 @@ type SubtaskRequest struct { } type TaskRequest struct { - Name string `json:"name"` - ProgressionBase *float64 `json:"progression_base,omitempty"` - RewardMessage *string `json:"reward_message,omitempty"` - RepetitionPeriod *string `json:"repetition_period,omitempty"` - RepetitionDate *string `json:"repetition_date,omitempty"` - WishlistID *int `json:"wishlist_id,omitempty"` - RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями - Rewards []RewardRequest `json:"rewards,omitempty"` - Subtasks []SubtaskRequest `json:"subtasks,omitempty"` + Name string `json:"name"` + ProgressionBase *float64 `json:"progression_base,omitempty"` + RewardMessage *string `json:"reward_message,omitempty"` + RepetitionPeriod *string `json:"repetition_period,omitempty"` + RepetitionDate *string `json:"repetition_date,omitempty"` + WishlistID *int `json:"wishlist_id,omitempty"` + RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями + Rewards []RewardRequest `json:"rewards,omitempty"` + Subtasks []SubtaskRequest `json:"subtasks,omitempty"` // Test-specific fields IsTest bool `json:"is_test,omitempty"` WordsCount *int `json:"words_count,omitempty"` @@ -316,35 +317,35 @@ type LinkedTask struct { } type WishlistItem struct { - ID int `json:"id"` - Name string `json:"name"` - Price *float64 `json:"price,omitempty"` - ImageURL *string `json:"image_url,omitempty"` - Link *string `json:"link,omitempty"` - Unlocked bool `json:"unlocked"` - Completed bool `json:"completed"` - FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"` - MoreLockedConditions int `json:"more_locked_conditions,omitempty"` - UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` - LinkedTask *LinkedTask `json:"linked_task,omitempty"` + ID int `json:"id"` + Name string `json:"name"` + Price *float64 `json:"price,omitempty"` + ImageURL *string `json:"image_url,omitempty"` + Link *string `json:"link,omitempty"` + Unlocked bool `json:"unlocked"` + Completed bool `json:"completed"` + FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"` + MoreLockedConditions int `json:"more_locked_conditions,omitempty"` + UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` + LinkedTask *LinkedTask `json:"linked_task,omitempty"` } type UnlockConditionDisplay struct { ID int `json:"id"` Type string `json:"type"` - TaskID *int `json:"task_id,omitempty"` // ID задачи (для task_completion) + TaskID *int `json:"task_id,omitempty"` // ID задачи (для task_completion) TaskName *string `json:"task_name,omitempty"` - ProjectID *int `json:"project_id,omitempty"` // ID проекта (для project_points) + ProjectID *int `json:"project_id,omitempty"` // ID проекта (для project_points) ProjectName *string `json:"project_name,omitempty"` RequiredPoints *float64 `json:"required_points,omitempty"` StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время DisplayOrder int `json:"display_order"` // Прогресс выполнения - CurrentPoints *float64 `json:"current_points,omitempty"` // Текущее количество баллов (для project_points) - TaskCompleted *bool `json:"task_completed,omitempty"` // Выполнена ли задача (для task_completion) + CurrentPoints *float64 `json:"current_points,omitempty"` // Текущее количество баллов (для project_points) + TaskCompleted *bool `json:"task_completed,omitempty"` // Выполнена ли задача (для task_completion) // Персональные цели - UserID *int `json:"user_id,omitempty"` // ID пользователя для персональных целей - UserName *string `json:"user_name,omitempty"` // Имя пользователя для персональных целей + UserID *int `json:"user_id,omitempty"` // ID пользователя для персональных целей + UserName *string `json:"user_name,omitempty"` // Имя пользователя для персональных целей } type WishlistRequest struct { @@ -355,7 +356,7 @@ type WishlistRequest struct { } type UnlockConditionRequest struct { - ID *int `json:"id,omitempty"` // ID существующего условия (для сохранения чужих условий) + ID *int `json:"id,omitempty"` // ID существующего условия (для сохранения чужих условий) Type string `json:"type"` TaskID *int `json:"task_id,omitempty"` ProjectID *int `json:"project_id,omitempty"` @@ -365,10 +366,10 @@ type UnlockConditionRequest struct { } type WishlistResponse struct { - Unlocked []WishlistItem `json:"unlocked"` - Locked []WishlistItem `json:"locked"` - Completed []WishlistItem `json:"completed,omitempty"` - CompletedCount int `json:"completed_count"` // Количество завершённых желаний + Unlocked []WishlistItem `json:"unlocked"` + Locked []WishlistItem `json:"locked"` + Completed []WishlistItem `json:"completed,omitempty"` + CompletedCount int `json:"completed_count"` // Количество завершённых желаний } // ============================================ @@ -1125,12 +1126,12 @@ func (a *App) claimOrphanedData(userID int) { WHERE table_name = $1 AND column_name = 'user_id' ) `, table).Scan(&columnExists) - + if err != nil || !columnExists { log.Printf("Skipping %s: user_id column does not exist (run migrations as table owner)", table) continue } - + result, err := a.DB.Exec(fmt.Sprintf("UPDATE %s SET user_id = $1 WHERE user_id IS NULL", table), userID) if err != nil { log.Printf("Error claiming orphaned data in %s: %v", table, err) @@ -1355,7 +1356,7 @@ func (a *App) addWordsHandler(w http.ResponseWriter, r *http.Request) { 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 @@ -1405,7 +1406,7 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { return } defer dictRows.Close() - + for dictRows.Next() { var dictID int if err := dictRows.Scan(&dictID); err != nil { @@ -1432,8 +1433,8 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { } // 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% + 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 @@ -1463,7 +1464,7 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { 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 { @@ -1529,7 +1530,7 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END, p.last_success_at ASC LIMIT $` + fmt.Sprintf("%d", len(group2Args)+1) - + group2Args = append(group2Args, group2Count) group2Rows, err := a.DB.Query(group2Query, group2Args...) if err != nil { @@ -1597,10 +1598,10 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { // 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", + + 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 := ` @@ -1611,7 +1612,7 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { 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 { @@ -1663,7 +1664,7 @@ func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { 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 @@ -1681,7 +1682,7 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } - + log.Printf("Received %d word updates, config_id: %v, user_id: %d", len(req.Words), req.ConfigID, userID) tx, err := a.DB.Begin() @@ -1722,7 +1723,7 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) } 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 != "" { @@ -1735,7 +1736,7 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) } else { lastFailure = nil } - + _, err := stmt.Exec( wordUpdate.ID, userID, @@ -2272,7 +2273,7 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Config created successfully", - "id": id, + "id": id, }) } @@ -2428,27 +2429,26 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { return } setCORSHeaders(w) - + userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } - + log.Printf("getWeeklyStatsHandler called from %s, path: %s, user: %d", r.RemoteAddr, r.URL.Path, userID) - // Опционально обновляем materialized view перед запросом - // Это можно сделать через query parameter ?refresh=true - if r.URL.Query().Get("refresh") == "true" { - _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") - if err != nil { - log.Printf("Warning: Failed to refresh materialized view: %v", err) - // Продолжаем выполнение даже если обновление не удалось - } + // Получаем данные текущей недели напрямую из nodes + currentWeekScores, err := a.getCurrentWeekScores(userID) + if err != nil { + log.Printf("Error getting current week scores: %v", err) + sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) + return } query := ` SELECT + p.id AS project_id, p.name AS project_name, -- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv COALESCE(wr.total_score, 0.0000) AS total_score, @@ -2486,11 +2486,13 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { for rows.Next() { var project WeeklyProjectStats + var projectID int var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( + &projectID, &project.ProjectName, &project.TotalScore, &minGoalScore, @@ -2503,6 +2505,11 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { return } + // Объединяем данные: если есть данные текущей недели, используем их вместо MV + if currentWeekScore, exists := currentWeekScores[projectID]; exists { + project.TotalScore = currentWeekScore + } + if minGoalScore.Valid { project.MinGoalScore = minGoalScore.Float64 } else { @@ -2567,7 +2574,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { // Вычисляем проценты для каждой группы groupsProgress := calculateGroupsProgress(groups) - + // Вычисляем общий процент выполнения total := calculateOverallProgress(groupsProgress) @@ -2668,7 +2675,7 @@ func (a *App) runMigrations() error { // This means an old dump was restored - set version to 1 without applying migration log.Println("Detected existing database schema without schema_migrations table") log.Println("Setting migration version to 1 (baseline) without applying migration") - + // Create schema_migrations table and set version to 1 _, err = a.DB.Exec(` CREATE TABLE IF NOT EXISTS schema_migrations ( @@ -2679,7 +2686,7 @@ func (a *App) runMigrations() error { if err != nil { return fmt.Errorf("failed to create schema_migrations table: %w", err) } - + _, err = a.DB.Exec(` INSERT INTO schema_migrations (version, dirty) VALUES (1, false) @@ -2688,7 +2695,7 @@ func (a *App) runMigrations() error { if err != nil { return fmt.Errorf("failed to set migration version: %w", err) } - + log.Println("Migration version set to 1 (baseline) for existing database") return nil } @@ -2729,7 +2736,7 @@ func (a *App) initPlayLifeDB() error { // DEPRECATED: All migration functions below are no longer used // Database migrations are now handled by golang-migrate // These functions are kept for reference only and will be removed in future versions -// +// // NOTE: Functions applyMigration012-029 have been removed as they are no longer needed. // All database schema is now managed by golang-migrate baseline migration. @@ -2746,7 +2753,7 @@ func (a *App) startWeeklyGoalsScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") log.Printf("Loading timezone for weekly goals scheduler: '%s'", timezoneStr) - + // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { @@ -2770,7 +2777,17 @@ func (a *App) startWeeklyGoalsScheduler() { // Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1) _, err = c.AddFunc("0 6 * * 1", func() { now := time.Now().In(loc) - log.Printf("Scheduled task: Setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) + log.Printf("Scheduled task: Refreshing weekly report MV and setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) + + // Сначала обновляем MV (чтобы в ней были данные прошлой недели) + _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") + if err != nil { + log.Printf("Error refreshing materialized view: %v", err) + } else { + log.Printf("Materialized view refreshed successfully") + } + + // Затем настраиваем цели на новую неделю if err := a.setupWeeklyGoals(); err != nil { log.Printf("Error in scheduled weekly goals setup: %v", err) } @@ -2785,17 +2802,94 @@ func (a *App) startWeeklyGoalsScheduler() { log.Println("Weekly goals scheduler started") } +// getCurrentWeekScores получает данные текущей недели напрямую из таблицы nodes для конкретного пользователя +// Возвращает map[project_id]total_score для текущей недели +func (a *App) getCurrentWeekScores(userID int) (map[int]float64, error) { + query := ` + SELECT + n.project_id, + COALESCE(SUM(n.score), 0) AS total_score + FROM nodes n + JOIN projects p ON n.project_id = p.id + WHERE + p.deleted = FALSE + AND p.user_id = $1 + AND n.user_id = $1 + AND EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND EXTRACT(WEEK FROM n.created_date)::INTEGER = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER + GROUP BY n.project_id + ` + + rows, err := a.DB.Query(query, userID) + if err != nil { + log.Printf("Error querying current week scores: %v", err) + return nil, fmt.Errorf("error querying current week scores: %w", err) + } + defer rows.Close() + + scores := make(map[int]float64) + for rows.Next() { + var projectID int + var totalScore float64 + if err := rows.Scan(&projectID, &totalScore); err != nil { + log.Printf("Error scanning current week scores row: %v", err) + return nil, fmt.Errorf("error scanning current week scores row: %w", err) + } + scores[projectID] = totalScore + } + + return scores, nil +} + +// getCurrentWeekScoresAllUsers получает данные текущей недели для всех пользователей +// Возвращает map[project_id]total_score для текущей недели +func (a *App) getCurrentWeekScoresAllUsers() (map[int]float64, error) { + query := ` + SELECT + n.project_id, + COALESCE(SUM(n.score), 0) AS total_score + FROM nodes n + JOIN projects p ON n.project_id = p.id + WHERE + p.deleted = FALSE + AND EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND EXTRACT(WEEK FROM n.created_date)::INTEGER = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER + GROUP BY n.project_id + ` + + rows, err := a.DB.Query(query) + if err != nil { + log.Printf("Error querying current week scores for all users: %v", err) + return nil, fmt.Errorf("error querying current week scores for all users: %w", err) + } + defer rows.Close() + + scores := make(map[int]float64) + for rows.Next() { + var projectID int + var totalScore float64 + if err := rows.Scan(&projectID, &totalScore); err != nil { + log.Printf("Error scanning current week scores row: %v", err) + return nil, fmt.Errorf("error scanning current week scores row: %w", err) + } + scores[projectID] = totalScore + } + + return scores, nil +} + // getWeeklyStatsData получает данные о проектах и их целях (без HTTP обработки) func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { - // Обновляем materialized view перед запросом - _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") + // Получаем данные текущей недели для всех пользователей + currentWeekScores, err := a.getCurrentWeekScoresAllUsers() if err != nil { - log.Printf("Warning: Failed to refresh materialized view: %v", err) - // Продолжаем выполнение даже если обновление не удалось + log.Printf("Error getting current week scores: %v", err) + return nil, fmt.Errorf("error getting current week scores: %w", err) } query := ` SELECT + p.id AS project_id, p.name AS project_name, -- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv COALESCE(wr.total_score, 0.0000) AS total_score, @@ -2832,11 +2926,13 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { for rows.Next() { var project WeeklyProjectStats + var projectID int var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( + &projectID, &project.ProjectName, &project.TotalScore, &minGoalScore, @@ -2848,6 +2944,11 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { return nil, fmt.Errorf("error scanning weekly stats row: %w", err) } + // Объединяем данные: если есть данные текущей недели, используем их вместо MV + if currentWeekScore, exists := currentWeekScores[projectID]; exists { + project.TotalScore = currentWeekScore + } + if minGoalScore.Valid { project.MinGoalScore = minGoalScore.Float64 } else { @@ -2912,7 +3013,7 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { // Вычисляем проценты для каждой группы groupsProgress := calculateGroupsProgress(groups) - + // Вычисляем общий процент выполнения total := calculateOverallProgress(groupsProgress) @@ -2929,14 +3030,16 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { // getWeeklyStatsDataForUser получает данные о проектах для конкретного пользователя func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error) { - // Обновляем materialized view перед запросом - _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") + // Получаем данные текущей недели напрямую из nodes + currentWeekScores, err := a.getCurrentWeekScores(userID) if err != nil { - log.Printf("Warning: Failed to refresh materialized view: %v", err) + log.Printf("Error getting current week scores: %v", err) + return nil, fmt.Errorf("error getting current week scores: %w", err) } query := ` SELECT + p.id AS project_id, p.name AS project_name, COALESCE(wr.total_score, 0.0000) AS total_score, wg.min_goal_score, @@ -2970,11 +3073,13 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error for rows.Next() { var project WeeklyProjectStats + var projectID int var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( + &projectID, &project.ProjectName, &project.TotalScore, &minGoalScore, @@ -2985,6 +3090,11 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error return nil, fmt.Errorf("error scanning weekly stats row: %w", err) } + // Объединяем данные: если есть данные текущей недели, используем их вместо MV + if currentWeekScore, exists := currentWeekScores[projectID]; exists { + project.TotalScore = currentWeekScore + } + if minGoalScore.Valid { project.MinGoalScore = minGoalScore.Float64 } else { @@ -3048,7 +3158,7 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error // Вычисляем проценты для каждой группы groupsProgress := calculateGroupsProgress(groups) - + // Вычисляем общий процент выполнения total := calculateOverallProgress(groupsProgress) @@ -3079,7 +3189,7 @@ func (a *App) formatDailyReport(data *WeeklyStatsResponse) string { if projectName == "" { projectName = "Без названия" } - + actualScore := item.TotalScore minGoal := item.MinGoalScore var maxGoal float64 @@ -3124,12 +3234,12 @@ func (a *App) formatDailyReport(data *WeeklyStatsResponse) string { // sendDailyReport отправляет персональные ежедневные отчеты всем пользователям func (a *App) sendDailyReport() error { log.Printf("Scheduled task: Sending daily reports") - + userIDs, err := a.getAllUsersWithTelegram() if err != nil { return fmt.Errorf("error getting users: %w", err) } - + if len(userIDs) == 0 { log.Printf("No users with Telegram connected, skipping daily report") return nil @@ -3163,7 +3273,7 @@ func (a *App) startDailyReportScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") log.Printf("Loading timezone for daily report scheduler: '%s'", timezoneStr) - + // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { @@ -3201,7 +3311,7 @@ func (a *App) startDailyReportScheduler() { // Запускаем планировщик c.Start() log.Printf("Daily report scheduler started: every day at 23:59 %s", timezoneStr) - + // Планировщик будет работать в фоновом режиме } @@ -3209,10 +3319,10 @@ func (a *App) startDailyReportScheduler() { func readVersion() string { // Пробуем разные пути к файлу VERSION paths := []string{ - "/app/VERSION", // В Docker контейнере - "../VERSION", // При запуске из play-life-backend/ - "../../VERSION", // Альтернативный путь - "VERSION", // Текущая директория + "/app/VERSION", // В Docker контейнере + "../VERSION", // При запуске из play-life-backend/ + "../../VERSION", // Альтернативный путь + "VERSION", // Текущая директория } for _, path := range paths { @@ -3278,7 +3388,7 @@ func main() { // Telegram бот теперь загружается из БД при необходимости // Webhook будет настроен автоматически при сохранении bot token через UI - + // JWT secret from env or generate random jwtSecret := getEnv("JWT_SECRET", "") if jwtSecret == "" { @@ -3288,7 +3398,7 @@ func main() { jwtSecret = base64.StdEncoding.EncodeToString(b) log.Printf("WARNING: JWT_SECRET not set, using randomly generated secret. Set JWT_SECRET env var for production.") } - + app := &App{ DB: db, lastWebhookTime: make(map[int]time.Time), @@ -3296,7 +3406,7 @@ func main() { telegramBotUsername: "", jwtSecret: []byte(jwtSecret), } - + // Инициализация Telegram бота из .env telegramBotToken := getEnv("TELEGRAM_BOT_TOKEN", "") if telegramBotToken != "" { @@ -3306,7 +3416,7 @@ func main() { } else { app.telegramBot = bot log.Printf("Telegram bot initialized successfully") - + // Получаем username бота через getMe botInfo, err := bot.GetMe() if err != nil { @@ -3315,7 +3425,7 @@ func main() { app.telegramBotUsername = botInfo.UserName log.Printf("Telegram bot username: @%s", app.telegramBotUsername) } - + // Настраиваем webhook для единого бота webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") if webhookBaseURL != "" { @@ -3347,46 +3457,46 @@ func main() { app.startDailyReportScheduler() r := mux.NewRouter() - + // Public auth routes (no authentication required) r.HandleFunc("/api/auth/register", app.registerHandler).Methods("POST", "OPTIONS") r.HandleFunc("/api/auth/login", app.loginHandler).Methods("POST", "OPTIONS") r.HandleFunc("/api/auth/refresh", app.refreshTokenHandler).Methods("POST", "OPTIONS") - + // Webhooks - no auth (external services) r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS") - + // Admin pages (basic access, consider adding auth later) r.HandleFunc("/admin", app.adminHandler).Methods("GET") r.HandleFunc("/admin.html", app.adminHandler).Methods("GET") - + // Static files handler для uploads (public, no auth required) - ДО protected! // Backend работает из /app/backend/, но uploads находится в /app/uploads/ r.HandleFunc("/uploads/{path:.*}", func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) path := vars["path"] filePath := filepath.Join("/app/uploads", path) - + // Проверяем, что файл существует if _, err := os.Stat(filePath); os.IsNotExist(err) { http.NotFound(w, r) return } - + // Отдаём файл http.ServeFile(w, r, filePath) }).Methods("GET") - + // Protected routes (require authentication) protected := r.PathPrefix("/").Subrouter() protected.Use(app.authMiddleware) - + // Auth routes that need authentication protected.HandleFunc("/api/auth/logout", app.logoutHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/auth/me", app.getMeHandler).Methods("GET", "OPTIONS") - + // Words & dictionaries protected.HandleFunc("/api/words", app.getWordsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/words", app.addWordsHandler).Methods("POST", "OPTIONS") @@ -3396,7 +3506,7 @@ func main() { protected.HandleFunc("/api/dictionaries", app.addDictionaryHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/dictionaries/{id}", app.updateDictionaryHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/dictionaries/{id}", app.deleteDictionaryHandler).Methods("DELETE", "OPTIONS") - + // Configs protected.HandleFunc("/api/configs", app.getConfigsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/configs", app.addConfigHandler).Methods("POST", "OPTIONS") @@ -3404,7 +3514,7 @@ func main() { protected.HandleFunc("/api/configs/{id}", app.deleteConfigHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/configs/{id}/dictionaries", app.getConfigDictionariesHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/test-configs-and-dictionaries", app.getTestConfigsAndDictionariesHandler).Methods("GET", "OPTIONS") - + // Projects & stats protected.HandleFunc("/api/weekly-stats", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/playlife-feed", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") @@ -3417,17 +3527,17 @@ func main() { protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/create", app.createProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS") - + // Integrations protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "OPTIONS") - + // Todoist OAuth endpoints protected.HandleFunc("/api/integrations/todoist/oauth/connect", app.todoistOAuthConnectHandler).Methods("GET") r.HandleFunc("/api/integrations/todoist/oauth/callback", app.todoistOAuthCallbackHandler).Methods("GET") // Публичный! protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS") - + // Tasks protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS") @@ -3437,14 +3547,14 @@ func main() { protected.HandleFunc("/api/tasks/{id}/complete", app.completeTaskHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/complete-and-delete", app.completeAndDeleteTaskHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS") - + // Wishlist protected.HandleFunc("/api/wishlist", app.getWishlistHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/completed", app.getWishlistCompletedHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/proxy-image", app.proxyImageHandler).Methods("GET", "OPTIONS") - + // Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!) protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/boards", app.createBoardHandler).Methods("POST", "OPTIONS") @@ -3460,7 +3570,7 @@ func main() { protected.HandleFunc("/api/wishlist/boards/{boardId}/completed", app.getBoardCompletedHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/invite/{token}", app.getBoardInviteInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/invite/{token}/join", app.joinBoardHandler).Methods("POST", "OPTIONS") - + // Wishlist items (после boards, чтобы {id} не перехватывал "boards") protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS") @@ -3469,7 +3579,7 @@ func main() { protected.HandleFunc("/api/wishlist/{id}/complete", app.completeWishlistHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}/copy", app.copyWishlistHandler).Methods("POST", "OPTIONS") - + // Admin operations protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") @@ -3501,49 +3611,49 @@ func getMapKeys(m map[string]interface{}) []string { func setupTelegramWebhook(botToken, webhookURL string) error { apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken) log.Printf("Setting up Telegram webhook: apiURL=%s, webhookURL=%s", apiURL, webhookURL) - + payload := map[string]string{ "url": webhookURL, } - + jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal webhook payload: %w", err) } - + // Создаем HTTP клиент с таймаутом client := &http.Client{ Timeout: 10 * time.Second, } - + resp, err := client.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) if err != nil { log.Printf("ERROR: Failed to send webhook setup request: %v", err) return fmt.Errorf("failed to send webhook setup request: %w", err) } defer resp.Body.Close() - + bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response body: %w", err) } log.Printf("Telegram API response: status=%d, body=%s", resp.StatusCode, string(bodyBytes)) - + if resp.StatusCode != http.StatusOK { return fmt.Errorf("telegram API returned status %d: %s", resp.StatusCode, string(bodyBytes)) } - + // Декодируем из уже прочитанных байтов var result map[string]interface{} if err := json.Unmarshal(bodyBytes, &result); err != nil { return fmt.Errorf("failed to decode response: %w", err) } - + if ok, _ := result["ok"].(bool); !ok { description, _ := result["description"].(string) return fmt.Errorf("telegram API returned error: %s", description) } - + return nil } @@ -3578,15 +3688,15 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { // Всего есть 3 группы: приоритет 1, приоритет 2, приоритет 0 // Вычисляем среднее для каждой группы, если она есть // Если группы нет, считаем её как 100% - + result := GroupsProgress{} - + // Обрабатываем все 3 возможных приоритета priorities := []int{1, 2, 0} - + for _, priorityVal := range priorities { scores, exists := groups[priorityVal] - + var avg float64 if !exists || len(scores) == 0 { // Если группы нет, считаем как 100% @@ -3604,18 +3714,18 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { // Для проектов без приоритета (priorityVal == 0) - специальная формула projectCount := float64(len(scores)) multiplier := 100.0 / (projectCount * 0.8) - + sum := 0.0 for _, score := range scores { // score уже в процентах (например, 80.0), переводим в долю (0.8) scoreAsDecimal := score / 100.0 sum += scoreAsDecimal * multiplier } - + avg = math.Min(120.0, sum) } } - + // Сохраняем результат в соответствующее поле avgRounded := roundToFourDecimals(avg) switch priorityVal { @@ -3627,7 +3737,7 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { result.Group0 = &avgRounded } } - + return result } @@ -3638,31 +3748,31 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { func calculateOverallProgress(groupsProgress GroupsProgress) *float64 { // Находим среднее между всеми тремя группами // Если какая-то группа отсутствует (nil), считаем её как 100% - + var group1Val, group2Val, group0Val float64 - + if groupsProgress.Group1 != nil { group1Val = *groupsProgress.Group1 } else { group1Val = 100.0 } - + if groupsProgress.Group2 != nil { group2Val = *groupsProgress.Group2 } else { group2Val = 100.0 } - + if groupsProgress.Group0 != nil { group0Val = *groupsProgress.Group0 } else { group0Val = 100.0 } - + overallProgress := (group1Val + group2Val + group0Val) / 3.0 // Всегда делим на 3, так как групп всегда 3 overallProgressRounded := roundToFourDecimals(overallProgress) total := &overallProgressRounded - + return total } @@ -3717,7 +3827,7 @@ func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, e if err != nil { return nil, fmt.Errorf("failed to generate start token: %w", err) } - + err = a.DB.QueryRow(` INSERT INTO telegram_integrations (user_id, start_token) VALUES ($1, $2) @@ -3759,7 +3869,6 @@ func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, e return &integration, nil } - // sendTelegramMessageToChat - отправляет сообщение в конкретный чат по chat_id func (a *App) sendTelegramMessageToChat(chatID int64, text string) error { if a.telegramBot == nil { @@ -3773,9 +3882,9 @@ func (a *App) sendTelegramMessageToChat(chatID int64, text string) error { _, err := a.telegramBot.Send(msg) if err != nil { // Проверяем, не заблокирован ли бот - if strings.Contains(err.Error(), "blocked") || - strings.Contains(err.Error(), "chat not found") || - strings.Contains(err.Error(), "bot was blocked") { + if strings.Contains(err.Error(), "blocked") || + strings.Contains(err.Error(), "chat not found") || + strings.Contains(err.Error(), "bot was blocked") { // Пользователь заблокировал бота - очищаем данные chatIDStr := strconv.FormatInt(chatID, 10) a.DB.Exec(` @@ -3787,7 +3896,7 @@ func (a *App) sendTelegramMessageToChat(chatID int64, text string) error { } return err } - + log.Printf("Message sent to chat_id=%d", chatID) return nil } @@ -3799,19 +3908,19 @@ func (a *App) sendTelegramMessageToUser(userID int, text string) error { SELECT chat_id FROM telegram_integrations WHERE user_id = $1 AND chat_id IS NOT NULL `, userID).Scan(&chatID) - + if err == sql.ErrNoRows || !chatID.Valid { return fmt.Errorf("telegram not connected for user %d", userID) } if err != nil { return err } - + chatIDInt, err := strconv.ParseInt(chatID.String, 10, 64) if err != nil { return fmt.Errorf("invalid chat_id format: %w", err) } - + return a.sendTelegramMessageToChat(chatIDInt, text) } @@ -3825,7 +3934,7 @@ func (a *App) getAllUsersWithTelegram() ([]int, error) { return nil, err } defer rows.Close() - + var userIDs []int for rows.Next() { var userID int @@ -3842,7 +3951,7 @@ func utf16OffsetToUTF8(text string, utf16Offset int) int { if utf16Offset >= len(utf16Runes) { return len(text) } - + // Конвертируем UTF-16 кодовые единицы обратно в UTF-8 байты runes := utf16.Decode(utf16Runes[:utf16Offset]) return len(string(runes)) @@ -3857,14 +3966,14 @@ func utf16LengthToUTF8(text string, utf16Offset, utf16Length int) int { 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 } @@ -3874,15 +3983,15 @@ func utf16LengthToUTF8(text string, utf16Offset, utf16Length int) int { // userID может быть nil, если пользователь не определен func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, userID *int) (*ProcessedEntry, error) { fullText = strings.TrimSpace(fullText) - + // Регулярное выражение: project+/-score (без **) scoreRegex := regexp.MustCompile(`^([а-яА-ЯёЁ\w]+)([+-])(\d+(?:\.\d+)?)$`) - + // Массив для хранения извлеченных элементов {project, score} scoreNodes := make([]ProcessedNode, 0) workingText := fullText placeholderIndex := 0 - + // Находим все элементы, выделенные жирным шрифтом boldEntities := make([]TelegramEntity, 0) for _, entity := range entities { @@ -3890,12 +3999,12 @@ func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, 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 @@ -3903,21 +4012,21 @@ func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, 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] @@ -3931,36 +4040,36 @@ func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, 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) @@ -3971,10 +4080,10 @@ func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, } } processedText := strings.Join(cleanedLines, "\n") - + // Используем текущее время в формате ISO 8601 (UTC) createdDate := time.Now().UTC().Format(time.RFC3339) - + // Вставляем данные в БД только если есть nodes if len(scoreNodes) > 0 { err := a.insertMessageData(processedText, createdDate, scoreNodes, userID) @@ -3987,7 +4096,7 @@ func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, processedText = fullText log.Printf("No nodes found in Telegram message, message will not be saved to database") } - + // Формируем ответ response := &ProcessedEntry{ Text: processedText, @@ -3996,9 +4105,9 @@ func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, Raw: fullText, Markdown: fullText, // Для Telegram markdown не нужен } - + // НЕ отправляем сообщение обратно в Telegram (в отличие от processMessage) - + return response, nil } @@ -4137,7 +4246,7 @@ func (a *App) messagePostHandler(w http.ResponseWriter, r *http.Request) { rawText = text } } - + // Если не нашли в body, пробуем напрямую if rawText == "" { if text, ok := rawReq["text"].(string); ok { @@ -4186,7 +4295,7 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr SELECT id FROM projects WHERE name = $1 AND user_id = $2 AND deleted = FALSE `, projectName, *userID).Scan(&existingID) - + if err == sql.ErrNoRows { // Проект не существует, создаем новый _, err = tx.Exec(` @@ -4215,7 +4324,7 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr SELECT id FROM projects WHERE name = $1 AND deleted = FALSE `, projectName).Scan(&existingID) - + if err == sql.ErrNoRows { // Проект не существует, создаем новый _, err = tx.Exec(` @@ -4265,36 +4374,32 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr WHERE name = $1 AND deleted = FALSE `, node.Project).Scan(&projectID) } - + if err == sql.ErrNoRows { return fmt.Errorf("project %s not found after insert", node.Project) } else if err != nil { return fmt.Errorf("failed to find project %s: %w", node.Project, err) } - - // Вставляем node с user_id + + // Вставляем node с user_id и created_date (денормализация) if userID != nil { _, err = tx.Exec(` - INSERT INTO nodes (project_id, entry_id, score, user_id) - VALUES ($1, $2, $3, $4) - `, projectID, entryID, node.Score, *userID) + INSERT INTO nodes (project_id, entry_id, score, user_id, created_date) + VALUES ($1, $2, $3, $4, $5) + `, projectID, entryID, node.Score, *userID, createdDate) } else { _, err = tx.Exec(` - INSERT INTO nodes (project_id, entry_id, score) - VALUES ($1, $2, $3) - `, projectID, entryID, node.Score) + INSERT INTO nodes (project_id, entry_id, score, created_date) + VALUES ($1, $2, $3, $4) + `, projectID, entryID, node.Score, createdDate) } 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) - // Не возвращаем ошибку, так как это не критично - } + // MV обновляется только по крону в понедельник в 6:00 утра + // Данные текущей недели берутся напрямую из nodes // Коммитим транзакцию if err := tx.Commit(); err != nil { @@ -4574,7 +4679,7 @@ func (a *App) weeklyGoalsSetupHandler(w http.ResponseWriter, r *http.Request) { for rows.Next() { var goal WeeklyGoalSetup var maxGoalScore sql.NullFloat64 - + err := rows.Scan( &goal.ProjectName, &goal.MinGoalScore, @@ -4625,7 +4730,7 @@ func (a *App) dailyReportTriggerHandler(w http.ResponseWriter, r *http.Request) 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" @@ -4646,7 +4751,7 @@ func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) { } } } - + http.ServeFile(w, r, adminPath) } @@ -4820,7 +4925,7 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) // Парсим входящий запрос - может быть как {body: [...]}, так и просто массив var projectsToUpdate []ProjectPriorityUpdate - + // Сначала пробуем декодировать как прямой массив var directArray []interface{} arrayErr := json.Unmarshal(bodyBytes, &directArray) @@ -4830,7 +4935,7 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) 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) @@ -4862,7 +4967,7 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) } } } - + // Если не получилось как массив (ошибка декодирования), пробуем как объект с body // НЕ пытаемся декодировать как объект, если массив декодировался успешно (даже если пустой) if len(projectsToUpdate) == 0 && arrayErr != nil { @@ -4880,7 +4985,7 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) 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) @@ -4919,7 +5024,7 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) sendErrorWithCORS(w, "No projects to update", http.StatusBadRequest) return } - + log.Printf("Successfully parsed %d projects to update", len(projectsToUpdate)) // Начинаем транзакцию @@ -5047,7 +5152,7 @@ func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "message": "Project renamed successfully", + "message": "Project renamed successfully", "project_id": req.ID, }) return @@ -5126,15 +5231,12 @@ func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) { 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) - } + // MV обновляется только по крону в понедельник в 6:00 утра + // Данные текущей недели берутся напрямую из nodes w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "message": "Project moved successfully", + "message": "Project moved successfully", "project_id": finalProjectID, }) } @@ -5207,11 +5309,8 @@ func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) { 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) - } + // MV обновляется только по крону в понедельник в 6:00 утра + // Данные текущей недели берутся напрямую из nodes w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ @@ -5278,8 +5377,8 @@ func (a *App) createProjectHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "message": "Project created successfully", - "project_id": projectID, + "message": "Project created successfully", + "project_id": projectID, "project_name": req.Name, }) } @@ -5427,14 +5526,14 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { // Извлекаем 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)) @@ -5475,7 +5574,7 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { return } - log.Printf("Processing Todoist task: title='%s' (len=%d), description='%s' (len=%d), combined='%s' (len=%d)", + 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) @@ -5514,7 +5613,7 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { 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) @@ -5583,7 +5682,7 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { chatID := message.Chat.ID chatIDStr := strconv.FormatInt(chatID, 10) - log.Printf("Telegram webhook: telegram_user_id=%d, chat_id=%d, text=%s", + log.Printf("Telegram webhook: telegram_user_id=%d, chat_id=%d, text=%s", telegramUserID, chatID, message.Text) // Обработка команды /start с токеном @@ -5591,13 +5690,13 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { parts := strings.Fields(message.Text) if len(parts) > 1 { startToken := parts[1] - + var userID int err := a.DB.QueryRow(` SELECT user_id FROM telegram_integrations WHERE start_token = $1 `, startToken).Scan(&userID) - + if err == nil { // Привязываем Telegram к пользователю telegramUserIDStr := strconv.FormatInt(telegramUserID, 10) @@ -5609,12 +5708,12 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { updated_at = CURRENT_TIMESTAMP WHERE user_id = $3 `, telegramUserIDStr, chatIDStr, userID) - + if err != nil { log.Printf("Error updating telegram integration: %v", err) } else { log.Printf("Telegram connected for user_id=%d", userID) - + // Приветственное сообщение welcomeMsg := "✅ Telegram успешно подключен к Play Life!\n\nТеперь вы будете получать уведомления и отчеты." if err := a.sendTelegramMessageToChat(chatID, welcomeMsg); err != nil { @@ -5629,7 +5728,7 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { // /start без токена a.sendTelegramMessageToChat(chatID, "Привет! Для подключения используйте ссылку из приложения Play Life.") } - + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) @@ -5642,7 +5741,7 @@ func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { SELECT user_id FROM telegram_integrations WHERE telegram_user_id = $1 `, telegramUserID).Scan(&userID) - + if err == sql.ErrNoRows { log.Printf("User not found for telegram_user_id=%d", telegramUserID) w.Header().Set("Content-Type", "application/json") @@ -5703,6 +5802,19 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { return } + // Получаем данные текущей недели + currentWeekScores, err := a.getCurrentWeekScores(userID) + if err != nil { + log.Printf("Error getting current week scores: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting current week scores: %v", err), http.StatusInternalServerError) + return + } + + // Получаем ISO год и неделю для текущей даты + now := time.Now() + _, currentWeekInt := now.ISOWeek() + currentYearInt := now.Year() + query := ` SELECT p.name AS project_name, @@ -5717,7 +5829,8 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { 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 + COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score, + p.id AS project_id FROM weekly_report_mv wr FULL OUTER JOIN @@ -5748,8 +5861,10 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { defer rows.Close() statistics := make([]FullStatisticsItem, 0) + for rows.Next() { var item FullStatisticsItem + var projectID int err := rows.Scan( &item.ProjectName, @@ -5758,6 +5873,7 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { &item.TotalScore, &item.MinGoalScore, &item.MaxGoalScore, + &projectID, ) if err != nil { log.Printf("Error scanning full statistics row: %v", err) @@ -5765,9 +5881,78 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { return } + // Если это текущая неделя, заменяем данные из MV на данные из nodes + if item.ReportYear == currentYearInt && item.ReportWeek == currentWeekInt { + if score, exists := currentWeekScores[projectID]; exists { + item.TotalScore = score + } + } + statistics = append(statistics, item) } + // Добавляем проекты текущей недели, которых нет в MV (новые проекты без исторических данных) + // Получаем goals для текущей недели + currentWeekGoalsQuery := ` + SELECT + p.id AS project_id, + p.name AS project_name, + COALESCE(wg.min_goal_score, 0.0000) AS min_goal_score, + COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score + FROM projects p + LEFT JOIN weekly_goals wg ON wg.project_id = p.id + AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER + WHERE p.deleted = FALSE AND p.user_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM weekly_report_mv wr + WHERE wr.project_id = p.id + AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND wr.report_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER + ) + ` + + goalsRows, err := a.DB.Query(currentWeekGoalsQuery, userID) + if err == nil { + defer goalsRows.Close() + existingProjects := make(map[int]bool) + for _, stat := range statistics { + if stat.ReportYear == currentYearInt && stat.ReportWeek == currentWeekInt { + // Найдем project_id по имени проекта (не идеально, но работает) + var pid int + if err := a.DB.QueryRow("SELECT id FROM projects WHERE name = $1 AND user_id = $2", stat.ProjectName, userID).Scan(&pid); err == nil { + existingProjects[pid] = true + } + } + } + + for goalsRows.Next() { + var projectID int + var projectName string + var minGoalScore, maxGoalScore float64 + if err := goalsRows.Scan(&projectID, &projectName, &minGoalScore, &maxGoalScore); err == nil { + // Добавляем только если проекта еще нет в статистике + if !existingProjects[projectID] { + totalScore := 0.0 + if score, exists := currentWeekScores[projectID]; exists { + totalScore = score + } + + _, weekISO := time.Now().ISOWeek() + item := FullStatisticsItem{ + ProjectName: projectName, + ReportYear: time.Now().Year(), + ReportWeek: weekISO, + TotalScore: totalScore, + MinGoalScore: minGoalScore, + MaxGoalScore: maxGoalScore, + } + statistics = append(statistics, item) + } + } + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(statistics) } @@ -5832,7 +6017,7 @@ func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Re return } setCORSHeaders(w) - + sendErrorWithCORS(w, "Bot token is now configured via TELEGRAM_BOT_TOKEN environment variable", http.StatusBadRequest) } @@ -5929,7 +6114,7 @@ func getTodoistUserInfo(accessToken string) (struct { data := url.Values{} data.Set("sync_token", "*") data.Set("resource_types", `["user"]`) - + req, err := http.NewRequest("POST", "https://api.todoist.com/sync/v9/sync", strings.NewReader(data.Encode())) if err != nil { log.Printf("Todoist API: failed to create request: %v", err) @@ -6051,7 +6236,7 @@ func (a *App) todoistOAuthConnectHandler(w http.ResponseWriter, r *http.Request) ) log.Printf("Todoist OAuth: returning auth URL for user_id=%d", userID) - + // Возвращаем JSON с URL для редиректа (frontend сделает редирект) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ @@ -6168,7 +6353,7 @@ func (a *App) getTodoistStatusHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ - "connected": true, + "connected": true, "todoist_email": todoistEmail.String, }) } @@ -6256,9 +6441,9 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { var subtaskProjectNames pq.StringArray err := rows.Scan( - &task.ID, - &task.Name, - &task.Completed, + &task.ID, + &task.Name, + &task.Completed, &lastCompletedAt, &nextShowAt, &repetitionPeriod, @@ -6318,7 +6503,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { allProjects[pn] = true } } - + task.ProjectNames = make([]string, 0, len(allProjects)) for pn := range allProjects { task.ProjectNames = append(task.ProjectNames, pn) @@ -6380,9 +6565,9 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { `, taskID, userID).Scan( &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &rewardPolicy, ) - + log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr) - + // Преобразуем в sql.NullString для совместимости if repetitionPeriodStr != "" { repetitionPeriod = sql.NullString{String: repetitionPeriodStr, Valid: true} @@ -6479,7 +6664,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { defer subtaskRows.Close() subtaskMap := make(map[int]*Subtask) subtaskIDs := make([]int, 0) - + for subtaskRows.Next() { var subtaskTask Task var subtaskRewardMessage sql.NullString @@ -6566,14 +6751,14 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { FROM wishlist_items WHERE id = $1 AND deleted = FALSE `, wishlistID.Int64).Scan(&wishlistName) - + if err == nil { unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID) if err != nil { log.Printf("Error checking wishlist unlock status: %v", err) unlocked = false } - + response.WishlistInfo = &WishlistInfo{ ID: int(wishlistID.Int64), Name: wishlistName, @@ -6593,14 +6778,14 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { FROM configs WHERE id = $1 `, configID.Int64).Scan(&wordsCount, &maxCards) - + if err == nil { response.WordsCount = &wordsCount if maxCards.Valid { maxCardsInt := int(maxCards.Int64) response.MaxCards = &maxCardsInt } - + // Загружаем связанные словари dictRows, err := a.DB.Query(` SELECT dictionary_id @@ -6636,14 +6821,14 @@ func (a *App) findProjectByName(projectName string, userID int) (int, error) { SELECT id FROM projects WHERE LOWER(name) = LOWER($1) AND user_id = $2 AND deleted = FALSE `, projectName, userID).Scan(&projectID) - + if err == sql.ErrNoRows { return 0, fmt.Errorf("project not found: %s", projectName) } if err != nil { return 0, fmt.Errorf("error finding project: %w", err) } - + return projectID, nil } @@ -6654,14 +6839,14 @@ func (a *App) findProjectByNameTx(tx *sql.Tx, projectName string, userID int) (i SELECT id FROM projects WHERE LOWER(name) = LOWER($1) AND user_id = $2 AND deleted = FALSE `, projectName, userID).Scan(&projectID) - + if err == sql.ErrNoRows { return 0, fmt.Errorf("project not found: %s", projectName) } if err != nil { return 0, fmt.Errorf("error finding project: %w", err) } - + return projectID, nil } @@ -6709,7 +6894,7 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { SELECT user_id, name FROM wishlist_items WHERE id = $1 AND deleted = FALSE `, *req.WishlistID).Scan(&wishlistOwnerID, &wishlistName) - + if err == sql.ErrNoRows { sendErrorWithCORS(w, "Wishlist item not found", http.StatusBadRequest) return @@ -6719,19 +6904,19 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist item: %v", err), http.StatusInternalServerError) return } - + if wishlistOwnerID != userID { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } - + // Проверяем, что нет другой задачи с таким wishlist_id var existingTaskID int err = a.DB.QueryRow(` SELECT id FROM tasks WHERE wishlist_id = $1 AND deleted = FALSE `, *req.WishlistID).Scan(&existingTaskID) - + if err != sql.ErrNoRows { if err != nil { log.Printf("Error checking existing task for wishlist: %v", err) @@ -6741,18 +6926,18 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest) return } - + // Если название задачи не указано или пустое, используем название желания if strings.TrimSpace(req.Name) == "" { req.Name = wishlistName } - + // Если сообщение награды не указано или пустое, устанавливаем "Выполнить желание: {TITLE}" if req.RewardMessage == nil || strings.TrimSpace(*req.RewardMessage) == "" { rewardMsg := fmt.Sprintf("Выполнить желание: %s", wishlistName) req.RewardMessage = &rewardMsg } - + // Задачи, привязанные к желанию, не могут быть периодическими if (req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "") || (req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "") { @@ -6764,7 +6949,7 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { return } } - + // Задачи, привязанные к желанию, не могут иметь прогрессию if req.ProgressionBase != nil { sendErrorWithCORS(w, "Tasks linked to wishlist items cannot have progression", http.StatusBadRequest) @@ -7179,7 +7364,7 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { return } } - + // Задачи, привязанные к желанию, не могут иметь прогрессию if req.ProgressionBase != nil { sendErrorWithCORS(w, "Tasks linked to wishlist items cannot have progression", http.StatusBadRequest) @@ -7989,10 +8174,10 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { // Если repetition_date установлен, вычисляем next_show_at // Если repetition_period не установлен и repetition_date не установлен, помечаем задачу как удаленную // Если repetition_period = "0 day" (или любое значение с 0), не обновляем last_completed_at - + // Проверяем наличие repetition_date (используем COALESCE, поэтому пустая строка означает отсутствие) hasRepetitionDate := repetitionDate.Valid && strings.TrimSpace(repetitionDate.String) != "" - + if hasRepetitionDate { // Есть repetition_date - вычисляем следующую дату показа @@ -8023,7 +8208,7 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { // Проверяем, является ли период нулевым (начинается с "0 ") periodStr := strings.TrimSpace(repetitionPeriod.String) isZeroPeriod := strings.HasPrefix(periodStr, "0 ") || periodStr == "0" - + if isZeroPeriod { // Период = 0: обновляем только счетчик, но не last_completed_at // Задача никогда не будет переноситься в выполненные @@ -8581,7 +8766,7 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) { // ============================================ // calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента -// Считает напрямую из таблицы nodes, фильтруя по дате entries +// Считает напрямую из таблицы nodes, используя денормализованное поле created_date func (a *App) calculateProjectPointsFromDate( projectID int, startDate sql.NullTime, @@ -8600,17 +8785,17 @@ func (a *App) calculateProjectPointsFromDate( `, projectID, userID).Scan(&totalScore) } else { // С указанной даты до текущего момента - // Считаем все nodes этого пользователя, где дата entry >= startDate + // Считаем все nodes этого пользователя, где дата created_date >= startDate // Используем DATE() для сравнения только по дате (без времени) + // Теперь используем nodes.created_date напрямую (без JOIN с entries) err = a.DB.QueryRow(` SELECT COALESCE(SUM(n.score), 0) FROM nodes n - JOIN entries e ON n.entry_id = e.id JOIN projects p ON n.project_id = p.id WHERE n.project_id = $1 AND n.user_id = $2 AND p.user_id = $2 - AND DATE(e.created_date) >= DATE($3) + AND DATE(n.created_date) >= DATE($3) `, projectID, userID, startDate.Time).Scan(&totalScore) } @@ -9005,7 +9190,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) WHERE t.wishlist_id = $1 AND t.deleted = FALSE LIMIT 1 `, item.ID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) - + if linkedTaskErr == nil && linkedTaskID.Valid { linkedTask := &LinkedTask{ ID: int(linkedTaskID.Int64), @@ -9051,7 +9236,7 @@ func (a *App) saveWishlistConditions( return fmt.Errorf("error getting existing conditions: %w", err) } defer rows.Close() - + for rows.Next() { var condID int var condUserID sql.NullInt64 @@ -9130,7 +9315,7 @@ func (a *App) saveWishlistConditions( } startDateStr := condition.StartDate - + // Получаем или создаём score_condition var scID int var startDateVal interface{} @@ -9360,7 +9545,7 @@ func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) { log.Printf("createWishlistHandler: decoded request - name='%s', price=%v, link='%s', conditions=%d", req.Name, req.Price, req.Link, len(req.UnlockConditions)) - + if req.UnlockConditions == nil { log.Printf("createWishlistHandler: WARNING - UnlockConditions is nil, initializing empty slice") req.UnlockConditions = []UnlockConditionRequest{} @@ -9459,14 +9644,14 @@ func (a *App) checkWishlistAccess(itemID int, userID int) (bool, int, sql.NullIn FROM wishlist_items WHERE id = $1 AND deleted = FALSE `, itemID).Scan(&itemUserID, &boardID) - + if err == sql.ErrNoRows { return false, 0, sql.NullInt64{}, err } if err != nil { return false, 0, sql.NullInt64{}, err } - + // Проверяем доступ: владелец ИЛИ участник доски hasAccess := itemUserID == userID if !hasAccess && boardID.Valid { @@ -9484,7 +9669,7 @@ func (a *App) checkWishlistAccess(itemID int, userID int) (bool, int, sql.NullIn } } } - + return hasAccess, itemUserID, boardID, nil } @@ -9522,7 +9707,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { sendErrorWithCORS(w, "Error getting wishlist item", http.StatusInternalServerError) return } - + log.Printf("Wishlist item found: id=%d, itemUserID=%d, boardID=%v, currentUserID=%d", itemID, itemUserID, boardID, userID) if !hasAccess { @@ -9530,7 +9715,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } - + log.Printf("Access granted for wishlist item: id=%d, itemUserID=%d, boardID=%v, currentUserID=%d", itemID, itemUserID, boardID, userID) // Сохраняем itemUserID для использования в качестве fallback, если conditionUserID NULL @@ -9728,7 +9913,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { WHERE t.wishlist_id = $1 AND t.deleted = FALSE LIMIT 1 `, itemID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) - + if err == nil && linkedTaskID.Valid { linkedTask := &LinkedTask{ ID: int(linkedTaskID.Int64), @@ -9778,7 +9963,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) return } - + log.Printf("updateWishlistHandler: itemID=%d, userID=%d", itemID, userID) // Проверяем доступ к желанию @@ -9799,7 +9984,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } - + log.Printf("updateWishlistHandler: Access granted: id=%d, userID=%d", itemID, userID) var req WishlistRequest @@ -9928,7 +10113,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Error getting item owner: %v", err) continue } - + item = &WishlistItem{ ID: itemID, Name: name, @@ -10434,7 +10619,7 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { FROM wishlist_items WHERE id = $1 AND deleted = FALSE `, itemID).Scan(&ownerID, &name, &price, &link, &imagePath, &boardID, &authorID) - + if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return @@ -10523,7 +10708,7 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { if link.Valid { linkVal = link.String } - + // Определяем значения для board_id и author_id var boardIDVal, authorIDVal interface{} if boardID.Valid { @@ -10535,7 +10720,7 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { // Если author_id не был установлен, используем текущего пользователя authorIDVal = userID } - + err = tx.QueryRow(` INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, completed, deleted) VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE) @@ -10561,18 +10746,18 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { if imagePath.Valid && imagePath.String != "" { // Получаем путь к оригинальному файлу uploadsDir := getEnv("UPLOADS_DIR", "/app/uploads") - + // Очищаем путь от /uploads/ в начале и query параметров cleanPath := imagePath.String cleanPath = strings.TrimPrefix(cleanPath, "/uploads/") if idx := strings.Index(cleanPath, "?"); idx != -1 { cleanPath = cleanPath[:idx] } - + originalPath := filepath.Join(uploadsDir, cleanPath) - + log.Printf("Copying image: imagePath=%s, cleanPath=%s, originalPath=%s", imagePath.String, cleanPath, originalPath) - + // Проверяем, существует ли файл if _, statErr := os.Stat(originalPath); statErr == nil { // Создаём директорию для нового желания @@ -10580,16 +10765,16 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { if mkdirErr := os.MkdirAll(newImageDir, 0755); mkdirErr != nil { log.Printf("Error creating image dir: %v", mkdirErr) } - + // Генерируем уникальное имя файла ext := filepath.Ext(cleanPath) randomBytes := make([]byte, 8) rand.Read(randomBytes) newFileName := fmt.Sprintf("%d_%s%s", newWishlistID, hex.EncodeToString(randomBytes), ext) newImagePath := filepath.Join(newImageDir, newFileName) - + log.Printf("New image path: %s", newImagePath) - + // Копируем файл srcFile, openErr := os.Open(originalPath) if openErr != nil { @@ -10866,7 +11051,7 @@ func (a *App) getBoardHandler(w http.ResponseWriter, r *http.Request) { a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2) `, boardID, userID).Scan(&isMember) - + if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return @@ -10929,7 +11114,7 @@ func (a *App) updateBoardHandler(w http.ResponseWriter, r *http.Request) { // Обновляем поля if strings.TrimSpace(req.Name) != "" { - _, err = a.DB.Exec(`UPDATE wishlist_boards SET name = $1, updated_at = NOW() WHERE id = $2`, + _, err = a.DB.Exec(`UPDATE wishlist_boards SET name = $1, updated_at = NOW() WHERE id = $2`, strings.TrimSpace(req.Name), boardID) if err != nil { log.Printf("Error updating board name: %v", err) @@ -10941,7 +11126,7 @@ func (a *App) updateBoardHandler(w http.ResponseWriter, r *http.Request) { if *req.InviteEnabled { var currentToken sql.NullString a.DB.QueryRow(`SELECT invite_token FROM wishlist_boards WHERE id = $1`, boardID).Scan(¤tToken) - + if !currentToken.Valid || currentToken.String == "" { token := generateInviteToken() _, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_enabled = TRUE, invite_token = $1, updated_at = NOW() WHERE id = $2`, @@ -11334,7 +11519,7 @@ func (a *App) joinBoardHandler(w http.ResponseWriter, r *http.Request) { // Проверяем что пользователь ещё не участник var exists bool - a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&exists) if exists { sendErrorWithCORS(w, "You are already a member of this board", http.StatusBadRequest) @@ -11402,7 +11587,7 @@ func (a *App) getBoardItemsHandler(w http.ResponseWriter, r *http.Request) { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } - + hasAccess := ownerID == userID if !hasAccess { var isMember bool @@ -11410,7 +11595,7 @@ func (a *App) getBoardItemsHandler(w http.ResponseWriter, r *http.Request) { boardID, userID).Scan(&isMember) hasAccess = isMember } - + if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return @@ -11437,7 +11622,7 @@ func (a *App) getBoardItemsHandler(w http.ResponseWriter, r *http.Request) { // Считаем завершённые var completedCount int - a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_items WHERE board_id = $1 AND completed = TRUE AND deleted = FALSE`, + a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_items WHERE board_id = $1 AND completed = TRUE AND deleted = FALSE`, boardID).Scan(&completedCount) response := WishlistResponse{ @@ -11479,7 +11664,7 @@ func (a *App) getBoardCompletedHandler(w http.ResponseWriter, r *http.Request) { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } - + hasAccess := ownerID == userID if !hasAccess { var isMember bool @@ -11487,7 +11672,7 @@ func (a *App) getBoardCompletedHandler(w http.ResponseWriter, r *http.Request) { boardID, userID).Scan(&isMember) hasAccess = isMember } - + if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return @@ -11922,7 +12107,7 @@ func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } - + hasAccess := ownerID == userID if !hasAccess { var isMember bool @@ -11930,7 +12115,7 @@ func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) { boardID, userID).Scan(&isMember) hasAccess = isMember } - + if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return @@ -12106,7 +12291,7 @@ func extractMetadataViaHTTP(targetURL string) (*LinkMetadataResponse, error) { } client := &http.Client{ - Timeout: 30 * time.Second, + Timeout: 30 * time.Second, Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { @@ -12632,4 +12817,3 @@ func decodeHTMLEntities(s string) string { } return s } - diff --git a/play-life-backend/migrations/000004_optimize_weekly_report_mv.down.sql b/play-life-backend/migrations/000004_optimize_weekly_report_mv.down.sql new file mode 100644 index 0000000..feedc27 --- /dev/null +++ b/play-life-backend/migrations/000004_optimize_weekly_report_mv.down.sql @@ -0,0 +1,67 @@ +-- Migration: Revert optimization of weekly_report_mv +-- Date: 2026-01-26 +-- +-- This migration reverts: +-- 1. Removes created_date column from nodes table +-- 2. Drops indexes +-- 3. Restores MV to original structure (include current week, use entries.created_date) + +-- ============================================ +-- Step 1: Recreate MV with original structure +-- ============================================ +DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv; + +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, + CASE + WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000) + ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score) + END AS normalized_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 +LEFT JOIN + weekly_goals wg + ON wg.project_id = p.id + AND wg.goal_year = agg.report_year + AND wg.goal_week = agg.report_week +WHERE + p.deleted = FALSE +ORDER BY + p.id, agg.report_year, agg.report_week +WITH DATA; + +CREATE INDEX idx_weekly_report_mv_project_year_week +ON weekly_report_mv(project_id, report_year, report_week); + +-- ============================================ +-- Step 2: Drop indexes +-- ============================================ +DROP INDEX IF EXISTS idx_nodes_project_user_created_date; +DROP INDEX IF EXISTS idx_nodes_created_date_user; + +-- ============================================ +-- Step 3: Remove created_date column from nodes +-- ============================================ +ALTER TABLE nodes +DROP COLUMN IF EXISTS created_date; + +COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot.'; diff --git a/play-life-backend/migrations/000004_optimize_weekly_report_mv.up.sql b/play-life-backend/migrations/000004_optimize_weekly_report_mv.up.sql new file mode 100644 index 0000000..97ba7be --- /dev/null +++ b/play-life-backend/migrations/000004_optimize_weekly_report_mv.up.sql @@ -0,0 +1,94 @@ +-- Migration: Optimize weekly_report_mv by denormalizing created_date into nodes and excluding current week from MV +-- Date: 2026-01-26 +-- +-- This migration: +-- 1. Adds created_date column to nodes table (denormalization to avoid JOIN with entries) +-- 2. Populates existing data from entries +-- 3. Creates indexes for optimized queries +-- 4. Updates MV to exclude current week and use nodes.created_date instead of entries.created_date + +-- ============================================ +-- Step 1: Add created_date column to nodes +-- ============================================ +ALTER TABLE nodes +ADD COLUMN created_date TIMESTAMP WITH TIME ZONE; + +-- ============================================ +-- Step 2: Populate existing data from entries +-- ============================================ +UPDATE nodes n +SET created_date = e.created_date +FROM entries e +WHERE n.entry_id = e.id; + +-- ============================================ +-- Step 3: Set NOT NULL constraint +-- ============================================ +ALTER TABLE nodes +ALTER COLUMN created_date SET NOT NULL; + +-- ============================================ +-- Step 4: Create indexes for optimized queries +-- ============================================ +-- Index for filtering by date and user (for current week queries) +CREATE INDEX IF NOT EXISTS idx_nodes_created_date_user +ON nodes(created_date, user_id); + +-- Index for queries with grouping by project (for current week queries) +CREATE INDEX IF NOT EXISTS idx_nodes_project_user_created_date +ON nodes(project_id, user_id, created_date); + +COMMENT ON INDEX idx_nodes_created_date_user IS 'Index for filtering nodes by created_date and user_id - optimized for current week queries'; +COMMENT ON INDEX idx_nodes_project_user_created_date IS 'Index for grouping nodes by project, user and created_date - optimized for current week aggregation queries'; + +-- ============================================ +-- Step 5: Recreate MV to exclude current week and use nodes.created_date +-- ============================================ +DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv; + +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, + CASE + WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000) + ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score) + END AS normalized_total_score +FROM + projects p +LEFT JOIN + ( + SELECT + n.project_id, + EXTRACT(ISOYEAR FROM n.created_date)::INTEGER AS report_year, + EXTRACT(WEEK FROM n.created_date)::INTEGER AS report_week, + SUM(n.score) AS total_score + FROM + nodes n + WHERE + -- Exclude current week: only include data from previous weeks + (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER) + OR (EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER + AND EXTRACT(WEEK FROM n.created_date)::INTEGER < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER) + GROUP BY + 1, 2, 3 + ) agg + ON p.id = agg.project_id +LEFT JOIN + weekly_goals wg + ON wg.project_id = p.id + AND wg.goal_year = agg.report_year + AND wg.goal_week = agg.report_week +WHERE + p.deleted = FALSE +ORDER BY + p.id, agg.report_year, agg.report_week +WITH DATA; + +-- Recreate index on MV +CREATE INDEX idx_weekly_report_mv_project_year_week +ON weekly_report_mv(project_id, report_year, report_week); + +COMMENT ON MATERIALIZED VIEW weekly_report_mv IS 'Materialized view aggregating weekly scores by project using ISOYEAR for correct week calculations at year boundaries. Includes all projects via LEFT JOIN. Adds normalized_total_score using weekly_goals.max_score snapshot. Contains only historical data (excludes current week). Uses nodes.created_date (denormalized) instead of entries.created_date.'; diff --git a/play-life-web/package.json b/play-life-web/package.json index 63917fc..8974151 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.0.6", + "version": "4.1.0", "type": "module", "scripts": { "dev": "vite",