package main import ( "bytes" "compress/gzip" "context" "crypto/rand" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "html" "io" "log" "math" "net/http" "net/url" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" "unicode/utf16" "image/jpeg" mathrand "math/rand" "github.com/chromedp/chromedp" "github.com/disintegration/imaging" 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/robfig/cron/v3" "golang.org/x/crypto/bcrypt" ) // Палитра из 30 контрастных цветов для проектов (HEX формат) // Используется для генерации случайного цвета при создании проекта // Должна быть синхронизирована с frontend (projectUtils.js) var projectColorsPalette = []string{ "#EF4444", // Красный "#F97316", // Оранжевый "#F59E0B", // Янтарный "#EAB308", // Желтый "#84CC16", // Лайм "#22C55E", // Зеленый "#10B981", // Изумрудный "#14B8A6", // Бирюзовый "#06B6D4", // Голубой "#0EA5E9", // Небесный "#3B82F6", // Синий "#6366F1", // Индиго "#8B5CF6", // Фиолетовый "#A855F7", // Пурпурный "#D946EF", // Фуксия "#EC4899", // Розовый "#F43F5E", // Розово-красный "#DC2626", // Темно-красный "#EA580C", // Темно-оранжевый "#CA8A04", // Темно-желтый "#65A30D", // Темно-лайм "#16A34A", // Темно-зеленый "#059669", // Темно-изумрудный "#0D9488", // Темно-бирюзовый "#0891B2", // Темно-голубой "#0284C7", // Темно-небесный "#2563EB", // Темно-синий "#4F46E5", // Темно-индиго "#7C3AED", // Темно-фиолетовый "#9333EA", // Темно-пурпурный } 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"` WordsCount int `json:"words_count"` MaxCards *int `json:"max_cards,omitempty"` } type ConfigRequest struct { WordsCount int `json:"words_count"` MaxCards *int `json:"max_cards,omitempty"` 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 { ProjectID int `json:"project_id"` 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"` TodayChange *float64 `json:"today_change,omitempty"` Color string `json:"color"` } type GroupsProgress struct { Group1 *float64 `json:"group1,omitempty"` Group2 *float64 `json:"group2,omitempty"` Group0 *float64 `json:"group0,omitempty"` } type WeeklyStatsResponse struct { Total *float64 `json:"total,omitempty"` GroupProgress1 *float64 `json:"group_progress_1,omitempty"` GroupProgress2 *float64 `json:"group_progress_2,omitempty"` GroupProgress0 *float64 `json:"group_progress_0,omitempty"` Projects []WeeklyProjectStats `json:"projects"` Wishes []WishlistItem `json:"wishes,omitempty"` PendingScoresByProject map[int]float64 `json:"pending_scores_by_project,omitempty"` PrioritiesConfirmed bool `json:"priorities_confirmed"` } 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"` } // ProjectScoreSampleMvRow represents one row from project_score_sample_mv type ProjectScoreSampleMvRow struct { ProjectID int `json:"project_id"` Score float64 `json:"score"` EntryMessage string `json:"entry_message"` UserID *int `json:"user_id,omitempty"` CreatedDate time.Time `json:"created_date"` } type Project struct { ProjectID int `json:"project_id"` ProjectName string `json:"project_name"` Priority *int `json:"priority,omitempty"` Color string `json:"color"` } 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"` NormalizedTotalScore float64 `json:"normalized_total_score"` Color string `json:"color"` } type TodayEntryNode struct { ProjectName string `json:"project_name"` Score float64 `json:"score"` Index int `json:"index"` } type TodayEntry struct { ID int `json:"id"` Text string `json:"text"` CreatedDate string `json:"created_date"` Nodes []TodayEntryNode `json:"nodes"` IsDraft bool `json:"is_draft"` TaskID *int `json:"task_id,omitempty"` } type TodoistWebhook struct { EventName string `json:"event_name"` EventData map[string]interface{} `json:"event_data"` } type TelegramEntity struct { Type string `json:"type"` Offset int `json:"offset"` Length int `json:"length"` } type TelegramChat struct { ID int64 `json:"id"` } type TelegramUser struct { ID int64 `json:"id"` } type TelegramMessage struct { Text string `json:"text"` Entities []TelegramEntity `json:"entities"` Chat TelegramChat `json:"chat"` From *TelegramUser `json:"from,omitempty"` } type TelegramWebhook struct { Message TelegramMessage `json:"message"` } // TelegramUpdate - структура для Telegram webhook (обычно это Update объект) type TelegramUpdate struct { UpdateID int `json:"update_id"` Message *TelegramMessage `json:"message,omitempty"` EditedMessage *TelegramMessage `json:"edited_message,omitempty"` } // Tracking structures type TrackingUserStats struct { UserID int `json:"user_id"` UserName string `json:"user_name"` IsCurrentUser bool `json:"is_current_user"` Total *float64 `json:"total,omitempty"` Projects []TrackingProjectStats `json:"projects"` PrioritiesConfirmedYear int `json:"priorities_confirmed_year"` PrioritiesConfirmedWeek int `json:"priorities_confirmed_week"` } type TrackingProjectStats struct { ProjectName string `json:"project_name"` CalculatedScore float64 `json:"calculated_score"` // процент выполнения Priority *int `json:"priority,omitempty"` } type TrackingStatsResponse struct { WeekNumber int `json:"week_number"` Year int `json:"year"` Users []TrackingUserStats `json:"users"` } type TrackingAccessResponse struct { Trackers []TrackingUser `json:"trackers"` // кто меня отслеживает Tracked []TrackingUser `json:"tracked"` // кого я отслеживаю } type TrackingUser struct { ID int `json:"id"` RelationID int `json:"relation_id"` // id записи в user_tracking для удаления Name string `json:"name"` Email string `json:"email"` CreatedAt time.Time `json:"created_at"` } type TrackingInviteInfo struct { UserID int `json:"user_id"` UserName string `json:"user_name"` } type TrackingInviteResponse struct { InviteURL string `json:"invite_url"` } // 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"` PurchaseConfigID *int `json:"purchase_config_id,omitempty"` RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями Position *int `json:"position,omitempty"` // Position for subtasks // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) ProjectNames []string `json:"project_names"` GroupName *string `json:"group_name,omitempty"` // Название группы задачи SubtasksCount int `json:"subtasks_count"` HasProgression bool `json:"has_progression"` AutoComplete bool `json:"auto_complete"` DraftProgressionValue *float64 `json:"draft_progression_value,omitempty"` DraftSubtasksCount *int `json:"draft_subtasks_count,omitempty"` } 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"` } // calculateRewardScore вычисляет score для награды на основе progression func calculateRewardScore(reward Reward, progressionValue *float64, progressionBase *float64) float64 { if reward.UseProgression && progressionBase != nil && progressionValue != nil && *progressionBase != 0 { return (*progressionValue / *progressionBase) * reward.Value } return reward.Value } type Subtask struct { Task Task `json:"task"` Rewards []Reward `json:"rewards"` } type WishlistInfo struct { ID int `json:"id"` Name string `json:"name"` Unlocked bool `json:"unlocked"` } type TaskDetail struct { 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"` DictionaryIDs []int `json:"dictionary_ids,omitempty"` PurchaseBoards []PurchaseBoardInfo `json:"purchase_boards,omitempty"` // Draft fields (only present if draft exists) DraftProgressionValue *float64 `json:"draft_progression_value,omitempty"` DraftSubtasks []DraftSubtask `json:"draft_subtasks,omitempty"` } type RewardRequest struct { Position int `json:"position"` ProjectName string `json:"project_name"` Value float64 `json:"value"` UseProgression bool `json:"use_progression"` } type SubtaskRequest struct { ID *int `json:"id,omitempty"` Name *string `json:"name,omitempty"` RewardMessage *string `json:"reward_message,omitempty"` Position *int `json:"position,omitempty"` Rewards []RewardRequest `json:"rewards,omitempty"` } type TaskRequest struct { Name string `json:"name"` ProgressionBase *float64 `json:"progression_base,omitempty"` RewardMessage *string `json:"reward_message,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionDate *string `json:"repetition_date,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"` RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями GroupName *string `json:"group_name,omitempty"` // Название группы задачи 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"` MaxCards *int `json:"max_cards,omitempty"` DictionaryIDs []int `json:"dictionary_ids,omitempty"` // Purchase-specific fields IsPurchase bool `json:"is_purchase,omitempty"` PurchaseBoards []PurchaseBoardRequest `json:"purchase_boards,omitempty"` } type PurchaseBoardRequest struct { BoardID int `json:"board_id"` GroupName *string `json:"group_name,omitempty"` } type PurchaseBoardInfo struct { BoardID int `json:"board_id"` BoardName string `json:"board_name"` GroupName *string `json:"group_name,omitempty"` } type CompleteTaskRequest struct { Value *float64 `json:"value,omitempty"` ChildrenTaskIDs []int `json:"children_task_ids,omitempty"` } type PostponeTaskRequest struct { NextShowAt *string `json:"next_show_at"` } // ============================================ // Task Draft structures // ============================================ type SaveDraftRequest struct { ProgressionValue *float64 `json:"progression_value,omitempty"` ClearProgressionValue *bool `json:"clear_progression_value,omitempty"` // true = сбросить прогрессию в null ChildrenTaskIDs *[]int `json:"children_task_ids,omitempty"` // только checked подзадачи, nil = не менять AutoComplete *bool `json:"auto_complete,omitempty"` // nil = не менять } type TaskDraft struct { ID int TaskID int UserID int ProgressionValue *float64 AutoComplete bool CreatedAt time.Time UpdatedAt time.Time } type TaskDraftSubtask struct { ID int TaskDraftID int SubtaskID int } type DraftSubtask struct { SubtaskID int `json:"subtask_id"` } // ============================================ // Wishlist structures // ============================================ type LinkedTask struct { ID int `json:"id"` Name string `json:"name"` Completed int `json:"completed"` NextShowAt *string `json:"next_show_at,omitempty"` UserID *int `json:"user_id,omitempty"` // ID пользователя-владельца задачи } 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"` Rejected bool `json:"rejected"` FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"` MoreLockedConditions int `json:"more_locked_conditions,omitempty"` LockedConditionsCount int `json:"locked_conditions_count,omitempty"` // Общее количество заблокированных условий UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` LinkedTask *LinkedTask `json:"linked_task,omitempty"` TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания GroupName *string `json:"group_name,omitempty"` // Название группы желания IsReady bool `json:"is_ready,omitempty"` // Желание готово к разблокировке (для экрана прогресса) } type UnlockConditionDisplay struct { ID int `json:"id"` Type string `json:"type"` TaskID *int `json:"task_id,omitempty"` // ID задачи (для task_completion) TaskName *string `json:"task_name,omitempty"` TaskNextShowAt *string `json:"task_next_show_at,omitempty"` // Дата следующего показа задачи (для task_completion) 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) // Персональные цели UserID *int `json:"user_id,omitempty"` // ID пользователя для персональных целей UserName *string `json:"user_name,omitempty"` // Имя пользователя для персональных целей // Срок разблокировки WeeksText *string `json:"weeks_text,omitempty"` // Отформатированный текст срока разблокировки } type WishlistRequest struct { Name string `json:"name"` Price *float64 `json:"price,omitempty"` Link *string `json:"link,omitempty"` GroupName *string `json:"group_name,omitempty"` // Название группы желания UnlockConditions []UnlockConditionRequest `json:"unlock_conditions,omitempty"` } type UnlockConditionRequest struct { ID *int `json:"id,omitempty"` // ID существующего условия (для сохранения чужих условий) Type string `json:"type"` TaskID *int `json:"task_id,omitempty"` ProjectID *int `json:"project_id,omitempty"` RequiredPoints *float64 `json:"required_points,omitempty"` StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время DisplayOrder *int `json:"display_order,omitempty"` } type WishlistResponse struct { Unlocked []WishlistItem `json:"unlocked"` Locked []WishlistItem `json:"locked"` Completed []WishlistItem `json:"completed,omitempty"` CompletedCount int `json:"completed_count"` // Количество завершённых желаний } // ============================================ // Wishlist Boards (доски желаний) // ============================================ type WishlistBoard struct { ID int `json:"id"` OwnerID int `json:"owner_id"` OwnerName string `json:"owner_name,omitempty"` Name string `json:"name"` InviteEnabled bool `json:"invite_enabled"` InviteToken *string `json:"invite_token,omitempty"` InviteURL *string `json:"invite_url,omitempty"` MemberCount int `json:"member_count"` IsOwner bool `json:"is_owner"` CreatedAt time.Time `json:"created_at"` } type BoardMember struct { ID int `json:"id"` UserID int `json:"user_id"` Name string `json:"name"` Email string `json:"email"` JoinedAt time.Time `json:"joined_at"` } type BoardRequest struct { Name string `json:"name"` InviteEnabled *bool `json:"invite_enabled,omitempty"` } type BoardInviteInfo struct { BoardID int `json:"board_id"` Name string `json:"name"` OwnerName string `json:"owner_name"` MemberCount int `json:"member_count"` } type JoinBoardResponse struct { Board WishlistBoard `json:"board"` Message string `json:"message"` } // ============================================ // Helper functions for repetition_date // ============================================ // calculateNextShowAtFromRepetitionDate calculates the next occurrence date based on repetition_date pattern // Formats: // - "N week" - Nth day of week (1=Monday, 7=Sunday) // - "N month" - Nth day of month (1-31) // - "MM-DD year" - specific date each year func calculateNextShowAtFromRepetitionDate(repetitionDate string, fromDate time.Time) *time.Time { if repetitionDate == "" { return nil } parts := strings.Fields(strings.TrimSpace(repetitionDate)) if len(parts) < 2 { return nil } value := parts[0] unit := strings.ToLower(parts[1]) // Start from tomorrow at midnight nextDate := time.Date(fromDate.Year(), fromDate.Month(), fromDate.Day(), 0, 0, 0, 0, fromDate.Location()) nextDate = nextDate.AddDate(0, 0, 1) switch unit { case "week": // N-th day of week (1=Monday, 7=Sunday) dayOfWeek, err := strconv.Atoi(value) if err != nil || dayOfWeek < 1 || dayOfWeek > 7 { return nil } // Go: Sunday=0, Monday=1, ..., Saturday=6 // Our format: Monday=1, ..., Sunday=7 // Convert our format to Go format targetGoDay := dayOfWeek % 7 // Monday(1)->1, Sunday(7)->0 currentGoDay := int(nextDate.Weekday()) daysUntil := (targetGoDay - currentGoDay + 7) % 7 if daysUntil == 0 { daysUntil = 7 // If same day, go to next week } nextDate = nextDate.AddDate(0, 0, daysUntil) case "month": // N-th day of month dayOfMonth, err := strconv.Atoi(value) if err != nil || dayOfMonth < 1 || dayOfMonth > 31 { return nil } // Find the next occurrence of this day for i := 0; i < 12; i++ { // Check up to 12 months ahead // Get the last day of the current month year, month, _ := nextDate.Date() lastDayOfMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, nextDate.Location()).Day() // Use the actual day (capped at last day of month if needed) actualDay := dayOfMonth if actualDay > lastDayOfMonth { actualDay = lastDayOfMonth } candidateDate := time.Date(year, month, actualDay, 0, 0, 0, 0, nextDate.Location()) // If this date is in the future (after fromDate), use it if candidateDate.After(fromDate) { nextDate = candidateDate break } // Otherwise, try next month nextDate = time.Date(year, month+1, 1, 0, 0, 0, 0, nextDate.Location()) } case "year": // MM-DD format (e.g., "02-01" for February 1st) dateParts := strings.Split(value, "-") if len(dateParts) != 2 { return nil } month, err1 := strconv.Atoi(dateParts[0]) day, err2 := strconv.Atoi(dateParts[1]) if err1 != nil || err2 != nil || month < 1 || month > 12 || day < 1 || day > 31 { return nil } // Find the next occurrence of this date year := nextDate.Year() candidateDate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, nextDate.Location()) // If this year's date has passed, use next year if !candidateDate.After(fromDate) { candidateDate = time.Date(year+1, time.Month(month), day, 0, 0, 0, 0, nextDate.Location()) } nextDate = candidateDate default: return nil } return &nextDate } // calculateNextShowAtFromRepetitionPeriod calculates the next show date by adding repetition_period to fromDate // Format: PostgreSQL INTERVAL string (e.g., "1 day", "2 weeks", "3 months" or "3 mons") // Note: PostgreSQL may return weeks as days (e.g., "7 days" instead of "1 week") func calculateNextShowAtFromRepetitionPeriod(repetitionPeriod string, fromDate time.Time) *time.Time { if repetitionPeriod == "" { return nil } parts := strings.Fields(strings.TrimSpace(repetitionPeriod)) if len(parts) < 2 { log.Printf("calculateNextShowAtFromRepetitionPeriod: invalid format, parts=%v", parts) return nil } value, err := strconv.Atoi(parts[0]) if err != nil { log.Printf("calculateNextShowAtFromRepetitionPeriod: failed to parse value '%s': %v", parts[0], err) return nil } unit := strings.ToLower(parts[1]) log.Printf("calculateNextShowAtFromRepetitionPeriod: value=%d, unit='%s'", value, unit) // Start from fromDate at midnight nextDate := time.Date(fromDate.Year(), fromDate.Month(), fromDate.Day(), 0, 0, 0, 0, fromDate.Location()) switch unit { case "minute", "minutes", "mins", "min": nextDate = nextDate.Add(time.Duration(value) * time.Minute) case "hour", "hours", "hrs", "hr": nextDate = nextDate.Add(time.Duration(value) * time.Hour) case "day", "days": // PostgreSQL может возвращать недели как дни (например, "7 days" вместо "1 week") // Если количество дней кратно 7, обрабатываем как недели if value%7 == 0 && value >= 7 { weeks := value / 7 nextDate = nextDate.AddDate(0, 0, weeks*7) } else { nextDate = nextDate.AddDate(0, 0, value) } case "week", "weeks", "wks", "wk": nextDate = nextDate.AddDate(0, 0, value*7) case "month", "months", "mons", "mon": nextDate = nextDate.AddDate(0, value, 0) log.Printf("calculateNextShowAtFromRepetitionPeriod: added %d months, result=%v", value, nextDate) case "year", "years", "yrs", "yr": nextDate = nextDate.AddDate(value, 0, 0) default: log.Printf("calculateNextShowAtFromRepetitionPeriod: unknown unit '%s'", unit) return nil } return &nextDate } // ============================================ // Auth types // ============================================ type User struct { ID int `json:"id"` Email string `json:"email"` Name *string `json:"name,omitempty"` PasswordHash string `json:"-"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` IsActive bool `json:"is_active"` IsAdmin bool `json:"is_admin"` LastLoginAt *time.Time `json:"last_login_at,omitempty"` } type LoginRequest struct { Email string `json:"email"` Password string `json:"password"` } type RegisterRequest struct { Email string `json:"email"` Password string `json:"password"` Name *string `json:"name,omitempty"` } type TokenResponse struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` User User `json:"user"` } type RefreshRequest struct { RefreshToken string `json:"refresh_token"` } type UserResponse struct { User User `json:"user"` } type JWTClaims struct { UserID int `json:"user_id"` jwt.RegisteredClaims } // Context key for user ID type contextKey string const userIDKey contextKey = "user_id" type App struct { DB *sql.DB webhookMutex sync.Mutex lastWebhookTime map[int]time.Time // config_id -> last webhook time telegramBot *tgbotapi.BotAPI telegramBotUsername string jwtSecret []byte } func setCORSHeaders(w http.ResponseWriter) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") } // ============================================ // Auth helper functions // ============================================ func hashPassword(password string) (string, error) { bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) return string(bytes), err } func checkPasswordHash(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } func generateRefreshToken() (string, error) { b := make([]byte, 32) _, err := rand.Read(b) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } // generateWebhookToken generates a unique token for webhook URL identification func generateWebhookToken() (string, error) { b := make([]byte, 24) // 24 bytes = 32 chars in base64 _, err := rand.Read(b) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(b), nil } // generateRandomProjectColor возвращает случайный цвет из предопределенной палитры func generateRandomProjectColor() string { if len(projectColorsPalette) == 0 { return "#3B82F6" // Fallback цвет } return projectColorsPalette[mathrand.Intn(len(projectColorsPalette))] } func (a *App) generateAccessToken(userID int) (string, error) { claims := JWTClaims{ UserID: userID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(a.jwtSecret) } func (a *App) validateAccessToken(tokenString string) (*JWTClaims, error) { token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return a.jwtSecret, nil }) if err != nil { return nil, err } if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { return claims, nil } return nil, fmt.Errorf("invalid token") } // getUserIDFromContext extracts user ID from request context func getUserIDFromContext(r *http.Request) (int, bool) { userID, ok := r.Context().Value(userIDKey).(int) return userID, ok } // ============================================ // Auth middleware // ============================================ func (a *App) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Handle CORS preflight if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } authHeader := r.Header.Get("Authorization") if authHeader == "" { sendErrorWithCORS(w, "Authorization header required", http.StatusUnauthorized) return } parts := strings.Split(authHeader, " ") if len(parts) != 2 || parts[0] != "Bearer" { sendErrorWithCORS(w, "Invalid authorization header format", http.StatusUnauthorized) return } claims, err := a.validateAccessToken(parts[1]) if err != nil { sendErrorWithCORS(w, "Invalid or expired token", http.StatusUnauthorized) return } // Add user_id to context ctx := context.WithValue(r.Context(), userIDKey, claims.UserID) next.ServeHTTP(w, r.WithContext(ctx)) }) } func (a *App) adminMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Handle CORS preflight if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } // Get user_id from context (should be set by authMiddleware) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Check if user is admin var isAdmin bool err := a.DB.QueryRow("SELECT is_admin FROM users WHERE id = $1", userID).Scan(&isAdmin) if err != nil { if err == sql.ErrNoRows { sendErrorWithCORS(w, "User not found", http.StatusNotFound) return } log.Printf("Error checking admin status: %v", err) sendErrorWithCORS(w, "Database error", http.StatusInternalServerError) return } if !isAdmin { sendErrorWithCORS(w, "Forbidden: Admin access required", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // ============================================ // Auth handlers // ============================================ func (a *App) registerHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) var req RegisterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.Email == "" || req.Password == "" { sendErrorWithCORS(w, "Email and password are required", http.StatusBadRequest) return } if len(req.Password) < 6 { sendErrorWithCORS(w, "Password must be at least 6 characters", http.StatusBadRequest) return } // Check if email already exists var existingID int err := a.DB.QueryRow("SELECT id FROM users WHERE email = $1", req.Email).Scan(&existingID) if err == nil { sendErrorWithCORS(w, "Email already registered", http.StatusConflict) return } if err != sql.ErrNoRows { log.Printf("Error checking existing user: %v", err) sendErrorWithCORS(w, "Database error", http.StatusInternalServerError) return } // Hash password passwordHash, err := hashPassword(req.Password) if err != nil { log.Printf("Error hashing password: %v", err) sendErrorWithCORS(w, "Error processing password", http.StatusInternalServerError) return } // Insert user var user User err = a.DB.QueryRow(` INSERT INTO users (email, password_hash, name, created_at, updated_at, is_active, is_admin) VALUES ($1, $2, $3, NOW(), NOW(), true, false) RETURNING id, email, name, created_at, updated_at, is_active, is_admin, last_login_at `, req.Email, passwordHash, req.Name).Scan( &user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.IsAdmin, &user.LastLoginAt, ) if err != nil { log.Printf("Error inserting user: %v", err) sendErrorWithCORS(w, "Error creating user", http.StatusInternalServerError) return } // Check if this is the first user - if so, claim all orphaned data var userCount int a.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount) if userCount == 1 { log.Printf("First user registered (ID: %d), claiming all orphaned data", user.ID) a.claimOrphanedData(user.ID) } // Generate tokens accessToken, err := a.generateAccessToken(user.ID) if err != nil { log.Printf("Error generating access token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } refreshToken, err := generateRefreshToken() if err != nil { log.Printf("Error generating refresh token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } // Hash and store refresh token refreshTokenHash, _ := hashPassword(refreshToken) _, err = a.DB.Exec(` INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3) `, user.ID, refreshTokenHash, nil) if err != nil { log.Printf("Error storing refresh token: %v", err) } // Update last login a.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = $1", user.ID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(TokenResponse{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: 86400, // 24 hours User: user, }) } func (a *App) loginHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.Email == "" || req.Password == "" { sendErrorWithCORS(w, "Email and password are required", http.StatusBadRequest) return } // Find user var user User err := a.DB.QueryRow(` SELECT id, email, password_hash, name, created_at, updated_at, is_active, is_admin, last_login_at FROM users WHERE email = $1 `, req.Email).Scan( &user.ID, &user.Email, &user.PasswordHash, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.IsAdmin, &user.LastLoginAt, ) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Invalid email or password", http.StatusUnauthorized) return } if err != nil { log.Printf("Error finding user: %v", err) sendErrorWithCORS(w, "Database error", http.StatusInternalServerError) return } if !user.IsActive { sendErrorWithCORS(w, "Account is disabled", http.StatusForbidden) return } // Check password if !checkPasswordHash(req.Password, user.PasswordHash) { sendErrorWithCORS(w, "Invalid email or password", http.StatusUnauthorized) return } // Check if there is any orphaned data - claim it for this user var orphanedDataCount int a.DB.QueryRow(` SELECT COUNT(*) FROM ( SELECT 1 FROM projects WHERE user_id IS NULL UNION ALL SELECT 1 FROM entries WHERE user_id IS NULL UNION ALL SELECT 1 FROM nodes WHERE user_id IS NULL UNION ALL SELECT 1 FROM dictionaries WHERE user_id IS NULL UNION ALL SELECT 1 FROM words WHERE user_id IS NULL UNION ALL SELECT 1 FROM progress WHERE user_id IS NULL UNION ALL SELECT 1 FROM configs WHERE user_id IS NULL UNION ALL SELECT 1 FROM telegram_integrations WHERE user_id IS NULL UNION ALL SELECT 1 FROM weekly_goals WHERE user_id IS NULL LIMIT 1 ) orphaned `).Scan(&orphanedDataCount) if orphanedDataCount > 0 { log.Printf("User %d logging in, claiming orphaned data from all tables", user.ID) a.claimOrphanedData(user.ID) } // Generate tokens accessToken, err := a.generateAccessToken(user.ID) if err != nil { log.Printf("Error generating access token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } refreshToken, err := generateRefreshToken() if err != nil { log.Printf("Error generating refresh token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } // Hash and store refresh token refreshTokenHash, _ := hashPassword(refreshToken) _, err = a.DB.Exec(` INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3) `, user.ID, refreshTokenHash, nil) if err != nil { log.Printf("Error storing refresh token: %v", err) } // Update last login a.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = $1", user.ID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(TokenResponse{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: 86400, // 24 hours User: user, }) } func (a *App) refreshTokenHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) var req RefreshRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.RefreshToken == "" { sendErrorWithCORS(w, "Refresh token is required", http.StatusBadRequest) return } // Find valid refresh token (expires_at is NULL for tokens without expiration) rows, err := a.DB.Query(` SELECT rt.id, rt.user_id, rt.token_hash, u.email, u.name, u.created_at, u.updated_at, u.is_active, u.is_admin, u.last_login_at FROM refresh_tokens rt JOIN users u ON rt.user_id = u.id WHERE rt.expires_at IS NULL OR rt.expires_at > NOW() `) if err != nil { log.Printf("Error querying refresh tokens: %v", err) sendErrorWithCORS(w, "Database error", http.StatusInternalServerError) return } defer rows.Close() var foundTokenID int var user User var tokenFound bool for rows.Next() { var tokenID int var tokenHash string err := rows.Scan(&tokenID, &user.ID, &tokenHash, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.IsAdmin, &user.LastLoginAt) if err != nil { continue } if checkPasswordHash(req.RefreshToken, tokenHash) { foundTokenID = tokenID tokenFound = true break } } if !tokenFound { sendErrorWithCORS(w, "Invalid or expired refresh token", http.StatusUnauthorized) return } if !user.IsActive { sendErrorWithCORS(w, "Account is disabled", http.StatusForbidden) return } // Generate new tokens FIRST before deleting old one to prevent race condition accessToken, err := a.generateAccessToken(user.ID) if err != nil { log.Printf("Error generating access token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } refreshToken, err := generateRefreshToken() if err != nil { log.Printf("Error generating refresh token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } // Store new refresh token FIRST refreshTokenHash, _ := hashPassword(refreshToken) _, err = a.DB.Exec(` INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3) `, user.ID, refreshTokenHash, nil) if err != nil { log.Printf("Error storing new refresh token: %v", err) sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError) return } // Delete old refresh token AFTER new one is successfully stored // This prevents race condition where multiple refresh requests might use the same token a.DB.Exec("DELETE FROM refresh_tokens WHERE id = $1", foundTokenID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(TokenResponse{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: 86400, // 24 hours User: user, }) } func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Delete all refresh tokens for this user a.DB.Exec("DELETE FROM refresh_tokens WHERE user_id = $1", userID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"}) } func (a *App) getMeHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var user User err := a.DB.QueryRow(` SELECT id, email, name, created_at, updated_at, is_active, is_admin, last_login_at FROM users WHERE id = $1 `, userID).Scan( &user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.IsAdmin, &user.LastLoginAt, ) if err != nil { log.Printf("Error finding user: %v", err) sendErrorWithCORS(w, "User not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(UserResponse{User: user}) } // claimOrphanedData assigns all data with NULL user_id to the specified user func (a *App) claimOrphanedData(userID int) { tables := []string{"projects", "entries", "nodes", "dictionaries", "words", "progress", "configs", "telegram_integrations", "weekly_goals"} for _, table := range tables { // First check if user_id column exists var columnExists bool err := a.DB.QueryRow(` SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'user_id' ) `, table).Scan(&columnExists) if err != nil || !columnExists { log.Printf("Skipping %s: user_id column does not exist (run migrations as table owner)", table) continue } result, err := a.DB.Exec(fmt.Sprintf("UPDATE %s SET user_id = $1 WHERE user_id IS NULL", table), userID) if err != nil { log.Printf("Error claiming orphaned data in %s: %v", table, err) } else { rowsAffected, _ := result.RowsAffected() if rowsAffected > 0 { log.Printf("Claimed %d orphaned rows in %s for user %d", rowsAffected, table, userID) } } } } func sendErrorWithCORS(w http.ResponseWriter, message string, statusCode int) { setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) json.NewEncoder(w).Encode(map[string]interface{}{ "error": message, }) } func (a *App) getWordsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Get dictionary_id from query parameter dictionaryIDStr := r.URL.Query().Get("dictionary_id") var dictionaryID *int if dictionaryIDStr != "" { if id, err := strconv.Atoi(dictionaryIDStr); err == nil { dictionaryID = &id } } query := ` SELECT w.id, w.name, w.translation, w.description, COALESCE(p.success, 0) as success, COALESCE(p.failure, 0) as failure, CASE WHEN p.last_success_at IS NOT NULL THEN p.last_success_at::text ELSE NULL END as last_success_at, CASE WHEN p.last_failure_at IS NOT NULL THEN p.last_failure_at::text ELSE NULL END as last_failure_at FROM words w JOIN dictionaries d ON w.dictionary_id = d.id LEFT JOIN progress p ON w.id = p.word_id AND p.user_id = $1 WHERE d.user_id = $1 AND ($2::INTEGER IS NULL OR w.dictionary_id = $2) ORDER BY w.id ` rows, err := a.DB.Query(query, userID, dictionaryID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() words := make([]Word, 0) for rows.Next() { var word Word var lastSuccess, lastFailure sql.NullString err := rows.Scan( &word.ID, &word.Name, &word.Translation, &word.Description, &word.Success, &word.Failure, &lastSuccess, &lastFailure, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if lastSuccess.Valid { word.LastSuccess = &lastSuccess.String } if lastFailure.Valid { word.LastFailure = &lastFailure.String } words = append(words, word) } setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(words) } func (a *App) addWordsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req WordsRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding addWords request: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } log.Printf("addWords: user_id=%d, words_count=%d", userID, len(req.Words)) tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() // Create default dictionary for user if needed var defaultDictID int err = tx.QueryRow(` SELECT id FROM dictionaries WHERE user_id = $1 ORDER BY id LIMIT 1 `, userID).Scan(&defaultDictID) if err == sql.ErrNoRows { // Create default dictionary for user log.Printf("Creating default dictionary for user_id=%d", userID) err = tx.QueryRow(` INSERT INTO dictionaries (name, user_id) VALUES ('Все слова', $1) RETURNING id `, userID).Scan(&defaultDictID) if err != nil { log.Printf("Error creating default dictionary: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } log.Printf("Created default dictionary id=%d for user_id=%d", defaultDictID, userID) } else if err != nil { log.Printf("Error finding default dictionary: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } else { log.Printf("Using default dictionary id=%d for user_id=%d", defaultDictID, userID) } stmt, err := tx.Prepare(` INSERT INTO words (name, translation, description, dictionary_id, user_id) VALUES ($1, $2, $3, $4, $5) RETURNING id `) if err != nil { log.Printf("Error preparing insert statement: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() var addedCount int for i, wordReq := range req.Words { var id int dictionaryID := defaultDictID if wordReq.DictionaryID != nil { dictionaryID = *wordReq.DictionaryID // Проверяем, что словарь принадлежит пользователю var dictUserID int err := tx.QueryRow(` SELECT user_id FROM dictionaries WHERE id = $1 `, dictionaryID).Scan(&dictUserID) if err == sql.ErrNoRows { log.Printf("Dictionary %d not found for word %d", dictionaryID, i) sendErrorWithCORS(w, fmt.Sprintf("Dictionary %d not found", dictionaryID), http.StatusBadRequest) return } else if err != nil { log.Printf("Error checking dictionary ownership: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if dictUserID != userID { log.Printf("Dictionary %d belongs to user %d, but request from user %d", dictionaryID, dictUserID, userID) sendErrorWithCORS(w, fmt.Sprintf("Dictionary %d does not belong to user", dictionaryID), http.StatusForbidden) return } } err := stmt.QueryRow(wordReq.Name, wordReq.Translation, wordReq.Description, dictionaryID, userID).Scan(&id) if err != nil { log.Printf("Error inserting word %d (name='%s', dict_id=%d, user_id=%d): %v", i, wordReq.Name, dictionaryID, userID, err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } addedCount++ log.Printf("Successfully added word id=%d: name='%s', dict_id=%d", id, wordReq.Name, dictionaryID) } if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } log.Printf("Successfully added %d words for user_id=%d", addedCount, userID) setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": fmt.Sprintf("Added %d words", addedCount), "added": addedCount, }) } func (a *App) deleteWordHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) wordID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid word ID", http.StatusBadRequest) return } // Verify ownership - check that word belongs to user var ownerID int err = a.DB.QueryRow("SELECT user_id FROM words WHERE id = $1", wordID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Word not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking word ownership: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if ownerID != userID { sendErrorWithCORS(w, "Word not found", http.StatusNotFound) return } // Delete the word (progress will be deleted automatically due to CASCADE) result, err := a.DB.Exec("DELETE FROM words WHERE id = $1", wordID) if err != nil { log.Printf("Error deleting word: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, err := result.RowsAffected() if err != nil { log.Printf("Error getting rows affected: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if rowsAffected == 0 { sendErrorWithCORS(w, "Word not found", http.StatusNotFound) return } setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Word deleted successfully", }) } func (a *App) resetWordProgressHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) wordID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid word ID", http.StatusBadRequest) return } // Verify ownership - check that word belongs to user var ownerID int err = a.DB.QueryRow("SELECT user_id FROM words WHERE id = $1", wordID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Word not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking word ownership: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if ownerID != userID { sendErrorWithCORS(w, "Word not found", http.StatusNotFound) return } // Reset progress for this word and user _, err = a.DB.Exec(` UPDATE progress SET success = 0, failure = 0, last_success_at = NULL, last_failure_at = NULL WHERE word_id = $1 AND user_id = $2 `, wordID, userID) if err != nil { log.Printf("Error resetting word progress: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Word progress reset successfully", }) } func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) { log.Printf("getTestWordsHandler called: %s %s", r.Method, r.URL.Path) setCORSHeaders(w) if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Get config_id from query parameter (required) configIDStr := r.URL.Query().Get("config_id") if configIDStr == "" { sendErrorWithCORS(w, "config_id parameter is required", http.StatusBadRequest) return } configID, err := strconv.Atoi(configIDStr) if err != nil { sendErrorWithCORS(w, "invalid config_id parameter", http.StatusBadRequest) return } // Get words_count from config (verify ownership) var wordsCount int err = a.DB.QueryRow("SELECT words_count FROM configs WHERE id = $1 AND user_id = $2", configID, userID).Scan(&wordsCount) if err != nil { if err == sql.ErrNoRows { sendErrorWithCORS(w, "config not found", http.StatusNotFound) return } sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Get dictionary IDs for this config var dictionaryIDs []int dictQuery := ` SELECT dictionary_id FROM config_dictionaries WHERE config_id = $1 ` dictRows, err := a.DB.Query(dictQuery, configID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer dictRows.Close() for dictRows.Next() { var dictID int if err := dictRows.Scan(&dictID); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } dictionaryIDs = append(dictionaryIDs, dictID) } // If no dictionaries are selected for config, use all dictionaries (no filter) var dictFilter string var dictArgs []interface{} if len(dictionaryIDs) > 0 { placeholders := make([]string, len(dictionaryIDs)) for i := range dictionaryIDs { placeholders[i] = fmt.Sprintf("$%d", i+1) } dictFilter = fmt.Sprintf("w.dictionary_id IN (%s)", strings.Join(placeholders, ",")) for _, dictID := range dictionaryIDs { dictArgs = append(dictArgs, dictID) } } else { dictFilter = "1=1" // No filter } // Calculate group sizes (use ceiling to ensure we don't lose words due to rounding) group1Count := int(float64(wordsCount) * 0.3) // 30% group2Count := int(float64(wordsCount) * 0.4) // 40% // group3Count is calculated dynamically based on actual words collected from groups 1 and 2 // Base query parts baseSelect := ` w.id, w.name, w.translation, w.description, COALESCE(p.success, 0) as success, COALESCE(p.failure, 0) as failure, CASE WHEN p.last_success_at IS NOT NULL THEN p.last_success_at::text ELSE NULL END as last_success_at, CASE WHEN p.last_failure_at IS NOT NULL THEN p.last_failure_at::text ELSE NULL END as last_failure_at ` baseFrom := fmt.Sprintf(` FROM words w JOIN dictionaries d ON w.dictionary_id = d.id AND d.user_id = %d LEFT JOIN progress p ON w.id = p.word_id AND p.user_id = %d WHERE `, userID, userID) + dictFilter // Group 1: success <= 3, sorted by success ASC, then last_success_at ASC (NULL first) group1Query := ` SELECT ` + baseSelect + ` ` + baseFrom + ` AND COALESCE(p.success, 0) <= 3 ORDER BY COALESCE(p.success, 0) ASC, CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END, p.last_success_at ASC LIMIT $` + fmt.Sprintf("%d", len(dictArgs)+1) group1Args := append(dictArgs, group1Count*2) // Get more to ensure uniqueness group1Rows, err := a.DB.Query(group1Query, group1Args...) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer group1Rows.Close() group1Words := make([]Word, 0) group1WordIDs := make(map[int]bool) for group1Rows.Next() && len(group1Words) < group1Count { var word Word var lastSuccess, lastFailure sql.NullString err := group1Rows.Scan( &word.ID, &word.Name, &word.Translation, &word.Description, &word.Success, &word.Failure, &lastSuccess, &lastFailure, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if lastSuccess.Valid { word.LastSuccess = &lastSuccess.String } if lastFailure.Valid { word.LastFailure = &lastFailure.String } group1Words = append(group1Words, word) group1WordIDs[word.ID] = true } // Group 2: sorted by (failure + 1)/(success + 1) DESC, take top 40% // Exclude words already in group1 group2Exclude := "" group2Args := make([]interface{}, 0) group2Args = append(group2Args, dictArgs...) if len(group1WordIDs) > 0 { excludePlaceholders := make([]string, 0, len(group1WordIDs)) idx := len(dictArgs) + 1 for wordID := range group1WordIDs { excludePlaceholders = append(excludePlaceholders, fmt.Sprintf("$%d", idx)) group2Args = append(group2Args, wordID) idx++ } group2Exclude = " AND w.id NOT IN (" + strings.Join(excludePlaceholders, ",") + ")" } group2Query := ` SELECT ` + baseSelect + ` ` + baseFrom + ` ` + group2Exclude + ` ORDER BY (COALESCE(p.failure, 0) + 1.0) / (COALESCE(p.success, 0) + 1.0) DESC, CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END, p.last_success_at ASC LIMIT $` + fmt.Sprintf("%d", len(group2Args)+1) group2Args = append(group2Args, group2Count) group2Rows, err := a.DB.Query(group2Query, group2Args...) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer group2Rows.Close() group2Words := make([]Word, 0) group2WordIDs := make(map[int]bool) for group2Rows.Next() { var word Word var lastSuccess, lastFailure sql.NullString err := group2Rows.Scan( &word.ID, &word.Name, &word.Translation, &word.Description, &word.Success, &word.Failure, &lastSuccess, &lastFailure, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if lastSuccess.Valid { word.LastSuccess = &lastSuccess.String } if lastFailure.Valid { word.LastFailure = &lastFailure.String } group2Words = append(group2Words, word) group2WordIDs[word.ID] = true } // Group 3: All remaining words, sorted by last_success_at ASC (NULL first) // Exclude words already in group1 and group2 allExcludedIDs := make(map[int]bool) for id := range group1WordIDs { allExcludedIDs[id] = true } for id := range group2WordIDs { allExcludedIDs[id] = true } group3Exclude := "" group3Args := make([]interface{}, 0) group3Args = append(group3Args, dictArgs...) if len(allExcludedIDs) > 0 { excludePlaceholders := make([]string, 0, len(allExcludedIDs)) idx := len(dictArgs) + 1 for wordID := range allExcludedIDs { excludePlaceholders = append(excludePlaceholders, fmt.Sprintf("$%d", idx)) group3Args = append(group3Args, wordID) idx++ } group3Exclude = " AND w.id NOT IN (" + strings.Join(excludePlaceholders, ",") + ")" } // Calculate how many words we still need from group 3 wordsCollected := len(group1Words) + len(group2Words) group3Needed := wordsCount - wordsCollected log.Printf("Word selection: wordsCount=%d, group1=%d, group2=%d, collected=%d, group3Needed=%d", wordsCount, len(group1Words), len(group2Words), wordsCollected, group3Needed) group3Words := make([]Word, 0) if group3Needed > 0 { group3Query := ` SELECT ` + baseSelect + ` ` + baseFrom + ` ` + group3Exclude + ` ORDER BY CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END, p.last_success_at ASC LIMIT $` + fmt.Sprintf("%d", len(group3Args)+1) group3Args = append(group3Args, group3Needed) group3Rows, err := a.DB.Query(group3Query, group3Args...) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer group3Rows.Close() for group3Rows.Next() { var word Word var lastSuccess, lastFailure sql.NullString err := group3Rows.Scan( &word.ID, &word.Name, &word.Translation, &word.Description, &word.Success, &word.Failure, &lastSuccess, &lastFailure, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if lastSuccess.Valid { word.LastSuccess = &lastSuccess.String } if lastFailure.Valid { word.LastFailure = &lastFailure.String } group3Words = append(group3Words, word) } } // Combine all groups words := make([]Word, 0) words = append(words, group1Words...) words = append(words, group2Words...) words = append(words, group3Words...) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(words) } func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) { log.Printf("updateTestProgressHandler called: %s %s", r.Method, r.URL.Path) setCORSHeaders(w) if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req TestProgressRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding request: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } log.Printf("Received %d word updates, config_id: %v, user_id: %d", len(req.Words), req.ConfigID, userID) tx, err := a.DB.Begin() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() // Create unique constraint for (word_id, user_id) if not exists tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS progress_word_user_unique ON progress(word_id, user_id)") stmt, err := tx.Prepare(` INSERT INTO progress (word_id, user_id, success, failure, last_success_at, last_failure_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (word_id, user_id) DO UPDATE SET success = EXCLUDED.success, failure = EXCLUDED.failure, last_success_at = COALESCE(EXCLUDED.last_success_at, progress.last_success_at), last_failure_at = COALESCE(EXCLUDED.last_failure_at, progress.last_failure_at) `) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() for _, wordUpdate := range req.Words { // Convert pointers to values for logging lastSuccessStr := "nil" if wordUpdate.LastSuccessAt != nil { lastSuccessStr = *wordUpdate.LastSuccessAt } lastFailureStr := "nil" if wordUpdate.LastFailureAt != nil { lastFailureStr = *wordUpdate.LastFailureAt } log.Printf("Updating word %d: success=%d, failure=%d, last_success_at=%s, last_failure_at=%s", wordUpdate.ID, wordUpdate.Success, wordUpdate.Failure, lastSuccessStr, lastFailureStr) // Convert pointers to sql.NullString for proper NULL handling var lastSuccess, lastFailure interface{} if wordUpdate.LastSuccessAt != nil && *wordUpdate.LastSuccessAt != "" { lastSuccess = *wordUpdate.LastSuccessAt } else { lastSuccess = nil } if wordUpdate.LastFailureAt != nil && *wordUpdate.LastFailureAt != "" { lastFailure = *wordUpdate.LastFailureAt } else { lastFailure = nil } _, err := stmt.Exec( wordUpdate.ID, userID, wordUpdate.Success, wordUpdate.Failure, lastSuccess, lastFailure, ) if err != nil { log.Printf("Error executing update for word %d: %v", wordUpdate.ID, err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } } if err := tx.Commit(); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Note: Reward message is now sent via completeTaskHandler when the test task is automatically completed. // The config_id is kept in the request for potential future use, but we no longer send messages here // to avoid duplicate messages (one from test completion, one from task completion). w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Progress updated successfully", }) } func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } query := ` SELECT id, words_count, max_cards FROM configs WHERE user_id = $1 ORDER BY id ` rows, err := a.DB.Query(query, userID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() configs := make([]Config, 0) for rows.Next() { var config Config var maxCards sql.NullInt64 err := rows.Scan( &config.ID, &config.WordsCount, &maxCards, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if maxCards.Valid { maxCardsVal := int(maxCards.Int64) config.MaxCards = &maxCardsVal } configs = append(configs, config) } setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(configs) } func (a *App) getDictionariesHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } query := ` SELECT d.id, d.name, COALESCE(COUNT(w.id), 0) as words_count FROM dictionaries d LEFT JOIN words w ON d.id = w.dictionary_id WHERE d.user_id = $1 GROUP BY d.id, d.name ORDER BY d.id ` rows, err := a.DB.Query(query, userID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() dictionaries := make([]Dictionary, 0) for rows.Next() { var dict Dictionary err := rows.Scan( &dict.ID, &dict.Name, &dict.WordsCount, ) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } dictionaries = append(dictionaries, dict) } setCORSHeaders(w) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(dictionaries) } func (a *App) addDictionaryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req DictionaryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { sendErrorWithCORS(w, "Имя словаря обязательно", http.StatusBadRequest) return } var id int err := a.DB.QueryRow(` INSERT INTO dictionaries (name, user_id) VALUES ($1, $2) RETURNING id `, req.Name, userID).Scan(&id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "id": id, "name": req.Name, }) } func (a *App) updateDictionaryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) dictionaryID := vars["id"] var req DictionaryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.Name == "" { sendErrorWithCORS(w, "Имя словаря обязательно", http.StatusBadRequest) return } result, err := a.DB.Exec(` UPDATE dictionaries SET name = $1 WHERE id = $2 AND user_id = $3 `, req.Name, dictionaryID, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, err := result.RowsAffected() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if rowsAffected == 0 { http.Error(w, "Dictionary not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Dictionary updated successfully", }) } func (a *App) deleteDictionaryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) dictionaryID := vars["id"] // Prevent deletion of default dictionary (id = 0) if dictionaryID == "0" { sendErrorWithCORS(w, "Cannot delete default dictionary", http.StatusBadRequest) return } // Verify ownership var ownerID int err := a.DB.QueryRow("SELECT user_id FROM dictionaries WHERE id = $1", dictionaryID).Scan(&ownerID) if err != nil || ownerID != userID { sendErrorWithCORS(w, "Dictionary not found", http.StatusNotFound) return } tx, err := a.DB.Begin() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() // Delete all words from this dictionary (progress will be deleted automatically due to CASCADE) _, err = tx.Exec(` DELETE FROM words WHERE dictionary_id = $1 `, dictionaryID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Delete all config-dictionary associations (will be deleted automatically due to CASCADE, but doing explicitly for clarity) _, err = tx.Exec(` DELETE FROM config_dictionaries WHERE dictionary_id = $1 `, dictionaryID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Delete the dictionary result, err := tx.Exec("DELETE FROM dictionaries WHERE id = $1", dictionaryID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, err := result.RowsAffected() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if rowsAffected == 0 { sendErrorWithCORS(w, "Dictionary not found", http.StatusNotFound) return } if err := tx.Commit(); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Dictionary deleted successfully. All words and configuration associations have been deleted.", }) } func (a *App) getConfigDictionariesHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.WriteHeader(http.StatusOK) return } vars := mux.Vars(r) configID := vars["id"] query := ` SELECT dictionary_id FROM config_dictionaries WHERE config_id = $1 ORDER BY dictionary_id ` rows, err := a.DB.Query(query, configID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() dictionaryIDs := make([]int, 0) for rows.Next() { var dictID int err := rows.Scan(&dictID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } dictionaryIDs = append(dictionaryIDs, dictID) } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "dictionary_ids": dictionaryIDs, }) } func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { log.Printf("getTestConfigsAndDictionariesHandler: Unauthorized request") sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } log.Printf("getTestConfigsAndDictionariesHandler called, user: %d", userID) // Get configs configsQuery := ` SELECT id, words_count, max_cards FROM configs WHERE user_id = $1 ORDER BY id ` configsRows, err := a.DB.Query(configsQuery, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer configsRows.Close() configs := make([]Config, 0) for configsRows.Next() { var config Config var maxCards sql.NullInt64 err := configsRows.Scan( &config.ID, &config.WordsCount, &maxCards, ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if maxCards.Valid { maxCardsVal := int(maxCards.Int64) config.MaxCards = &maxCardsVal } configs = append(configs, config) } // Get dictionaries dictsQuery := ` SELECT d.id, d.name, COALESCE(COUNT(w.id), 0) as words_count FROM dictionaries d LEFT JOIN words w ON d.id = w.dictionary_id WHERE d.user_id = $1 GROUP BY d.id, d.name ORDER BY d.id ` dictsRows, err := a.DB.Query(dictsQuery, userID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer dictsRows.Close() dictionaries := make([]Dictionary, 0) for dictsRows.Next() { var dict Dictionary err := dictsRows.Scan( &dict.ID, &dict.Name, &dict.WordsCount, ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } dictionaries = append(dictionaries, dict) } response := TestConfigsAndDictionariesResponse{ Configs: configs, Dictionaries: dictionaries, } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(response) } func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req ConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.WordsCount <= 0 { sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest) return } tx, err := a.DB.Begin() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() var id int err = tx.QueryRow(` INSERT INTO configs (words_count, max_cards, user_id) VALUES ($1, $2, $3) RETURNING id `, req.WordsCount, req.MaxCards, userID).Scan(&id) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Insert dictionary associations if provided if len(req.DictionaryIDs) > 0 { stmt, err := tx.Prepare(` INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2) `) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() for _, dictID := range req.DictionaryIDs { _, err := stmt.Exec(id, dictID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } } if err := tx.Commit(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Config created successfully", "id": id, }) } func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) configID := vars["id"] // Verify ownership var ownerID int err := a.DB.QueryRow("SELECT user_id FROM configs WHERE id = $1", configID).Scan(&ownerID) if err != nil || ownerID != userID { sendErrorWithCORS(w, "Config not found", http.StatusNotFound) return } var req ConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if req.WordsCount <= 0 { sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest) return } tx, err := a.DB.Begin() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() result, err := tx.Exec(` UPDATE configs SET words_count = $1, max_cards = $2 WHERE id = $3 `, req.WordsCount, req.MaxCards, configID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, err := result.RowsAffected() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if rowsAffected == 0 { http.Error(w, "Config not found", http.StatusNotFound) return } // Delete existing dictionary associations _, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", configID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // Insert new dictionary associations if provided if len(req.DictionaryIDs) > 0 { stmt, err := tx.Prepare(` INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2) `) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer stmt.Close() for _, dictID := range req.DictionaryIDs { _, err := stmt.Exec(configID, dictID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } } if err := tx.Commit(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Config updated successfully", }) } func (a *App) deleteConfigHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) configID := vars["id"] result, err := a.DB.Exec("DELETE FROM configs WHERE id = $1 AND user_id = $2", configID, userID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, err := result.RowsAffected() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if rowsAffected == 0 { sendErrorWithCORS(w, "Config not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Config deleted successfully", }) } func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } log.Printf("getWeeklyStatsHandler called from %s, path: %s, user: %d", r.RemoteAddr, r.URL.Path, userID) // Получаем данные текущей недели напрямую из 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 } // Получаем pending баллы из драфтов с auto_complete=true draftPendingScores, err := a.getDraftPendingScores(userID) if err != nil { log.Printf("Error getting draft pending scores: %v", err) // Не прерываем выполнение, продолжаем без pending scores } else { // Добавляем pending scores к currentWeekScores for projectID, pendingScore := range draftPendingScores { currentWeekScores[projectID] += pendingScore } } // Получаем сегодняшние приросты todayScores, err := a.getTodayScores(userID) if err != nil { log.Printf("Error getting today scores: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Добавляем pending scores из драфтов к todayScores (они будут начислены сегодня в конце дня) for projectID, pendingScore := range draftPendingScores { todayScores[projectID] += pendingScore } 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, wg.min_goal_score, wg.max_goal_score, wg.priority AS priority, p.color FROM projects p LEFT JOIN weekly_goals wg ON wg.project_id = p.id AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER LEFT JOIN weekly_report_mv wr ON p.id = wr.project_id AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week WHERE p.deleted = FALSE AND p.user_id = $1 ORDER BY total_score DESC ` rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying weekly stats: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() projects := make([]WeeklyProjectStats, 0) // Группы для расчета среднего по priority groups := make(map[int][]float64) for rows.Next() { var project WeeklyProjectStats var projectID int var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &projectID, &project.ProjectName, &project.TotalScore, &minGoalScore, &maxGoalScore, &priority, &project.Color, ) if err != nil { log.Printf("Error scanning weekly stats row: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } project.ProjectID = projectID // Объединяем данные: если есть данные текущей недели, используем их вместо MV if currentWeekScore, exists := currentWeekScores[projectID]; exists { project.TotalScore = currentWeekScore } // Добавляем сегодняшний прирост if todayScore, exists := todayScores[projectID]; exists && todayScore != 0 { project.TodayChange = &todayScore } if minGoalScore.Valid { project.MinGoalScore = minGoalScore.Float64 } else { project.MinGoalScore = 0 } if maxGoalScore.Valid { maxGoalVal := maxGoalScore.Float64 project.MaxGoalScore = &maxGoalVal } var priorityVal int if priority.Valid { priorityVal = int(priority.Int64) project.Priority = &priorityVal } // Расчет calculated_score по формуле из n8n totalScore := project.TotalScore minGoalScoreVal := project.MinGoalScore var maxGoalScoreVal float64 if project.MaxGoalScore != nil { maxGoalScoreVal = *project.MaxGoalScore } // Параметры бонуса в зависимости от priority var extraBonusLimit float64 = 20 if priorityVal == 1 { extraBonusLimit = 50 } else if priorityVal == 2 { extraBonusLimit = 35 } // Расчет calculated_score по логике фронтенда // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета var resultScore float64 if minGoalScoreVal <= 0 { // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) resultScore = 0 } else if totalScore < minGoalScoreVal { // До достижения minGoal растем линейно от 0 до 100% resultScore = (totalScore / minGoalScoreVal) * 100.0 } else { // Достигнут minGoal - базовый прогресс = 100% baseProgress := 100.0 // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс if maxGoalScoreVal > minGoalScoreVal { extraRange := maxGoalScoreVal - minGoalScoreVal excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal extraRatio := min(1.0, max(0.0, excess/extraRange)) extraProgress := extraRatio * extraBonusLimit resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) } else { // Если maxGoal не задан или некорректен, просто 100% resultScore = baseProgress } } project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета // Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения if minGoalScoreVal > 0 { if _, exists := groups[priorityVal]; !exists { groups[priorityVal] = make([]float64, 0) } groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) } projects = append(projects, project) } // Вычисляем проценты для каждой группы groupsProgress := calculateGroupsProgress(groups) // Вычисляем общий процент выполнения total := calculateOverallProgress(groupsProgress, groups) // Загружаем желания пользователя allWishes, err := a.getWishlistItemsWithConditions(userID, false) if err != nil { log.Printf("Error getting wishlist items for weekly stats: %v", err) allWishes = []WishlistItem{} } // Создаём map projectId -> minGoalScore для расчёта dailyScore projectMinGoalScores := make(map[int]float64) for _, p := range projects { projectMinGoalScores[p.ProjectID] = p.MinGoalScore } // Фильтруем желания для экрана прогресса недели wishes := a.filterWishesForWeekProgress(allWishes, projectMinGoalScores) pendingByProject := draftPendingScores if pendingByProject == nil { pendingByProject = make(map[int]float64) } confirmed, err := a.isPrioritiesConfirmedForUser(userID) if err != nil { log.Printf("Error checking priorities confirmation for user %d: %v", userID, err) confirmed = false } response := WeeklyStatsResponse{ Total: total, GroupProgress1: groupsProgress.Group1, GroupProgress2: groupsProgress.Group2, GroupProgress0: groupsProgress.Group0, Projects: projects, Wishes: wishes, PendingScoresByProject: pendingByProject, PrioritiesConfirmed: confirmed, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // runMigrations applies database migrations using golang-migrate func (a *App) runMigrations() error { migrationsPath := "migrations" if _, err := os.Stat(migrationsPath); os.IsNotExist(err) { // Try alternative path for Docker migrationsPath = "/migrations" if _, err := os.Stat(migrationsPath); os.IsNotExist(err) { return fmt.Errorf("migrations directory not found") } } // Get database connection string from environment 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") // Build database URL with proper encoding for special characters in password // url.UserPassword properly encodes special characters like ^, @, etc. userInfo := url.UserPassword(dbUser, dbPassword) databaseURL := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable", userInfo.String(), dbHost, dbPort, dbName) log.Printf("Migrations path: %s", migrationsPath) // Create migrate instance m, err := migrate.New( fmt.Sprintf("file://%s", migrationsPath), databaseURL, ) if err != nil { return fmt.Errorf("failed to initialize migrations: %w", err) } defer m.Close() // Check if schema_migrations table exists and its state var schemaExists bool var currentVersion int64 var isDirty bool err = a.DB.QueryRow(` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'schema_migrations' ) `).Scan(&schemaExists) if err != nil { log.Printf("Warning: Could not check schema_migrations table: %v", err) } // If schema_migrations exists, check its state if schemaExists { err = a.DB.QueryRow(` SELECT version, dirty FROM schema_migrations LIMIT 1 `).Scan(¤tVersion, &isDirty) if err != nil { log.Printf("Warning: Could not read schema_migrations: %v", err) schemaExists = false // Treat as if it doesn't exist } else if isDirty { // Database is in dirty state - fix it log.Println("Detected dirty migration state, fixing...") _, err = a.DB.Exec(` UPDATE schema_migrations SET dirty = false WHERE version = $1 `, currentVersion) if err != nil { return fmt.Errorf("failed to fix dirty migration state: %w", err) } log.Printf("Fixed dirty migration state for version %d", currentVersion) // Continue to apply migrations normally } else { log.Printf("Current DB migration version: %d", currentVersion) } } // If schema_migrations doesn't exist, check if database has existing tables // This handles the case when an old dump was restored if !schemaExists { var tableCount int err = a.DB.QueryRow(` SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name NOT IN ('schema_migrations') `).Scan(&tableCount) if err == nil && tableCount > 0 { // Database has existing tables but no schema_migrations // 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 ( version bigint NOT NULL PRIMARY KEY, dirty boolean NOT NULL ) `) 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) ON CONFLICT (version) DO UPDATE SET dirty = false `) 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 } } // Apply migrations normally if err := m.Up(); err != nil { if err == migrate.ErrNoChange { log.Println("Database is up to date, no migrations to apply") return nil } return fmt.Errorf("failed to apply migrations: %w", err) } log.Println("Database migrations applied successfully") return nil } func (a *App) initDB() error { // This function is kept for backward compatibility but does nothing // Database schema is now managed by golang-migrate return nil } func (a *App) initAuthDB() error { // Clean up expired refresh tokens (only those with expiration date set) // This is business logic that should run on startup a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()") return nil } func (a *App) initPlayLifeDB() error { // This function is kept for backward compatibility but does nothing // Database schema is now managed by golang-migrate return nil } // 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. // DEPRECATED: initPlayLifeDBOld is no longer used - schema is managed by golang-migrate func (a *App) initPlayLifeDBOld() error { // This function is kept for backward compatibility but does nothing // Database schema is now managed by golang-migrate return nil } // startWeeklyGoalsScheduler запускает планировщик для автоматической фиксации целей на неделю // каждый понедельник в 6:00 утра в указанном часовом поясе func (a *App) startWeeklyGoalsScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") log.Printf("Loading timezone for weekly goals scheduler: '%s'", timezoneStr) // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'") loc = time.UTC timezoneStr = "UTC" } else { log.Printf("Weekly goals scheduler timezone set to: %s", timezoneStr) } // Логируем текущее время в указанном часовом поясе для проверки now := time.Now().In(loc) log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) log.Printf("Next weekly goals setup will be on Monday at: 06:00 %s (cron: '0 6 * * 1')", timezoneStr) // Создаем планировщик с указанным часовым поясом c := cron.New(cron.WithLocation(loc)) // Добавляем задачу: каждый понедельник в 6:00 утра // Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1) _, err = c.AddFunc("0 6 * * 1", func() { now := time.Now().In(loc) log.Printf("Scheduled task: Refreshing materialized views 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") } // Обновляем project_score_sample_mv _, err = a.DB.Exec("REFRESH MATERIALIZED VIEW project_score_sample_mv") if err != nil { log.Printf("Error refreshing project_score_sample_mv: %v", err) } else { log.Printf("Project score sample materialized view refreshed successfully") } // Затем настраиваем цели на новую неделю if err := a.setupWeeklyGoals(); err != nil { log.Printf("Error in scheduled weekly goals setup: %v", err) } }) if err != nil { log.Printf("Warning: Failed to add weekly goals scheduler: %v", err) return } // Запускаем планировщик c.Start() 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 } // getDraftPendingScores получает потенциальные баллы из драфтов с auto_complete=true // Эти баллы будут начислены при автовыполнении задач в конце дня // Возвращает map[project_id]pending_score func (a *App) getDraftPendingScores(userID int) (map[int]float64, error) { // Получаем все драфты с auto_complete=true для пользователя // Включаем progression_base из задачи для расчёта score query := ` SELECT td.task_id, td.progression_value, t.progression_base FROM task_drafts td JOIN tasks t ON td.task_id = t.id WHERE td.user_id = $1 AND td.auto_complete = TRUE AND t.deleted = FALSE ` rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying draft pending scores: %v", err) return nil, fmt.Errorf("error querying draft pending scores: %w", err) } defer rows.Close() scores := make(map[int]float64) for rows.Next() { var taskID int var progressionValue sql.NullFloat64 var progressionBase sql.NullFloat64 if err := rows.Scan(&taskID, &progressionValue, &progressionBase); err != nil { log.Printf("Error scanning draft row: %v", err) continue } // Получаем reward_configs для основной задачи rewardRows, err := a.DB.Query(` SELECT rc.project_id, rc.value, rc.use_progression FROM reward_configs rc WHERE rc.task_id = $1 `, taskID) if err != nil { log.Printf("Error querying task rewards for draft: %v", err) continue } var progressionValuePtr *float64 if progressionValue.Valid { progressionValuePtr = &progressionValue.Float64 } var progressionBasePtr *float64 if progressionBase.Valid { progressionBasePtr = &progressionBase.Float64 } for rewardRows.Next() { var projectID int var rewardValue float64 var useProgression bool if err := rewardRows.Scan(&projectID, &rewardValue, &useProgression); err != nil { log.Printf("Error scanning reward row: %v", err) continue } reward := Reward{ Value: rewardValue, UseProgression: useProgression, } score := calculateRewardScore(reward, progressionValuePtr, progressionBasePtr) scores[projectID] += score } rewardRows.Close() // Получаем отмеченные подзадачи из драфта subtaskRows, err := a.DB.Query(` SELECT tds.subtask_id, t.progression_base FROM task_draft_subtasks tds JOIN task_drafts td ON tds.task_draft_id = td.id JOIN tasks t ON tds.subtask_id = t.id WHERE td.task_id = $1 AND td.user_id = $2 `, taskID, userID) if err != nil { log.Printf("Error querying draft subtasks: %v", err) continue } for subtaskRows.Next() { var subtaskID int var subtaskProgressionBase sql.NullFloat64 if err := subtaskRows.Scan(&subtaskID, &subtaskProgressionBase); err != nil { log.Printf("Error scanning subtask row: %v", err) continue } // Определяем progression_base для подзадачи var subtaskProgressionBasePtr *float64 if subtaskProgressionBase.Valid { subtaskProgressionBasePtr = &subtaskProgressionBase.Float64 } else if progressionBase.Valid { subtaskProgressionBasePtr = &progressionBase.Float64 } // Получаем награды подзадачи subtaskRewardRows, err := a.DB.Query(` SELECT rc.project_id, rc.value, rc.use_progression FROM reward_configs rc WHERE rc.task_id = $1 `, subtaskID) if err != nil { log.Printf("Error querying subtask rewards: %v", err) continue } for subtaskRewardRows.Next() { var projectID int var rewardValue float64 var useProgression bool if err := subtaskRewardRows.Scan(&projectID, &rewardValue, &useProgression); err != nil { log.Printf("Error scanning subtask reward row: %v", err) continue } reward := Reward{ Value: rewardValue, UseProgression: useProgression, } score := calculateRewardScore(reward, progressionValuePtr, subtaskProgressionBasePtr) scores[projectID] += score } subtaskRewardRows.Close() } subtaskRows.Close() } return scores, nil } // getAutoCompleteDraftEntries возвращает драфты с auto_complete=true как TodayEntry для отображения в списке записей func (a *App) getAutoCompleteDraftEntries(userID int) ([]TodayEntry, error) { rows, err := a.DB.Query(` SELECT td.task_id, t.name, COALESCE(t.reward_message, ''), td.progression_value, t.progression_base FROM task_drafts td JOIN tasks t ON td.task_id = t.id WHERE td.user_id = $1 AND td.auto_complete = TRUE AND t.deleted = FALSE ORDER BY td.updated_at DESC `, userID) if err != nil { return nil, fmt.Errorf("error querying auto complete drafts: %w", err) } defer rows.Close() entries := make([]TodayEntry, 0) for rows.Next() { var taskID int var taskName string var rewardMessageStr string var progressionValue sql.NullFloat64 var progressionBase sql.NullFloat64 if err := rows.Scan(&taskID, &taskName, &rewardMessageStr, &progressionValue, &progressionBase); err != nil { log.Printf("Error scanning auto complete draft row: %v", err) continue } var progressionValuePtr *float64 if progressionValue.Valid { progressionValuePtr = &progressionValue.Float64 } var progressionBasePtr *float64 if progressionBase.Valid { progressionBasePtr = &progressionBase.Float64 } // Получаем ноды (reward_configs) для задачи rewardRows, err := a.DB.Query(` SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = $1 ORDER BY rc.position `, taskID) if err != nil { log.Printf("Error querying rewards for draft task %d: %v", taskID, err) continue } nodes := make([]TodayEntryNode, 0) for rewardRows.Next() { var position int var projectName string var rewardValue float64 var useProgression bool if err := rewardRows.Scan(&position, &projectName, &rewardValue, &useProgression); err != nil { log.Printf("Error scanning reward row for draft: %v", err) continue } reward := Reward{ Value: rewardValue, UseProgression: useProgression, } score := calculateRewardScore(reward, progressionValuePtr, progressionBasePtr) nodes = append(nodes, TodayEntryNode{ ProjectName: projectName, Score: score, Index: position, }) } rewardRows.Close() // Текст отдаём как есть (с плейсхолдерами $0, $1), форматирование делает фронтенд через formatEntryText var entryText string if rewardMessageStr != "" { entryText = rewardMessageStr } else { entryText = taskName } // Вычисляем следующий свободный индекс для нод подзадач nextNodeIndex := 0 for _, node := range nodes { if node.Index >= nextNodeIndex { nextNodeIndex = node.Index + 1 } } // Получаем checked подзадачи из task_draft_subtasks для этого драфта subtaskRows, err := a.DB.Query(` SELECT t.id, t.name, COALESCE(t.reward_message, ''), t.progression_base FROM task_draft_subtasks tds JOIN task_drafts td ON tds.task_draft_id = td.id JOIN tasks t ON tds.subtask_id = t.id WHERE td.task_id = $1 AND td.user_id = $2 AND t.deleted = FALSE `, taskID, userID) if err != nil { log.Printf("Error querying draft subtasks for task %d: %v", taskID, err) } else { for subtaskRows.Next() { var subtaskID int var subtaskName string var subtaskRewardMsg string var subtaskProgressionBase sql.NullFloat64 if err := subtaskRows.Scan(&subtaskID, &subtaskName, &subtaskRewardMsg, &subtaskProgressionBase); err != nil { log.Printf("Error scanning subtask row for draft: %v", err) continue } // Пропускаем подзадачи без reward_message if subtaskRewardMsg == "" { continue } // Получаем ноды подзадачи subtaskRewardRows, err := a.DB.Query(` SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = $1 ORDER BY rc.position `, subtaskID) if err != nil { log.Printf("Error querying subtask rewards for draft subtask %d: %v", subtaskID, err) continue } type subtaskRewardEntry struct { position int projectName string rewardValue float64 useProgression bool } subtaskRewards := make([]subtaskRewardEntry, 0) for subtaskRewardRows.Next() { var sre subtaskRewardEntry if err := subtaskRewardRows.Scan(&sre.position, &sre.projectName, &sre.rewardValue, &sre.useProgression); err != nil { log.Printf("Error scanning subtask reward row for draft: %v", err) continue } subtaskRewards = append(subtaskRewards, sre) } subtaskRewardRows.Close() // Определяем progression base для подзадачи var subtaskProgressionBasePtr *float64 if subtaskProgressionBase.Valid { subtaskProgressionBasePtr = &subtaskProgressionBase.Float64 } else if progressionBase.Valid { subtaskProgressionBasePtr = &progressionBase.Float64 } // Строим map позиция → новый глобальный индекс и добавляем ноды positionToIndex := make(map[int]int) for _, sre := range subtaskRewards { reward := Reward{ Value: sre.rewardValue, UseProgression: sre.useProgression, } score := calculateRewardScore(reward, progressionValuePtr, subtaskProgressionBasePtr) positionToIndex[sre.position] = nextNodeIndex nodes = append(nodes, TodayEntryNode{ ProjectName: sre.projectName, Score: score, Index: nextNodeIndex, }) nextNodeIndex++ } // Переписываем reward_message подзадачи, заменяя $position на ${globalIndex} subtaskText := subtaskRewardMsg // Заменяем $subtaskName subtaskText = strings.ReplaceAll(subtaskText, "$subtaskName", subtaskName) // Заменяем $name subtaskText = strings.ReplaceAll(subtaskText, "$name", taskName) // Заменяем ${pos} и $pos на ${globalIndex} for pos, globalIdx := range positionToIndex { newPlaceholder := fmt.Sprintf("${%d}", globalIdx) subtaskText = strings.ReplaceAll(subtaskText, fmt.Sprintf("${%d}", pos), newPlaceholder) } for i := 99; i >= 0; i-- { if globalIdx, ok := positionToIndex[i]; ok { newPlaceholder := fmt.Sprintf("${%d}", globalIdx) re := regexp.MustCompile(fmt.Sprintf(`\$%d(\D|$)`, i)) subtaskText = re.ReplaceAllStringFunc(subtaskText, func(match string) string { suffix := []rune(match)[len([]rune(fmt.Sprintf("$%d", i))):] return newPlaceholder + string(suffix) }) } } entryText += "\n + " + subtaskText } subtaskRows.Close() } taskIDCopy := taskID entries = append(entries, TodayEntry{ IsDraft: true, TaskID: &taskIDCopy, Text: entryText, Nodes: nodes, }) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("error iterating auto complete draft rows: %w", err) } return entries, nil } // getTodayScores получает сумму score всех нод, созданных сегодня для конкретного пользователя // Возвращает map[project_id]today_score для сегодняшнего дня func (a *App) getTodayScores(userID int) (map[int]float64, error) { // Получаем часовой пояс из переменной окружения (по умолчанию 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 timezoneStr = "UTC" } // Вычисляем текущую дату в нужном часовом поясе now := time.Now().In(loc) todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) todayEnd := todayStart.Add(24 * time.Hour) query := ` SELECT n.project_id, COALESCE(SUM(n.score), 0) AS today_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 n.created_date >= $2 AND n.created_date < $3 GROUP BY n.project_id ` rows, err := a.DB.Query(query, userID, todayStart, todayEnd) if err != nil { log.Printf("Error querying today scores: %v", err) return nil, fmt.Errorf("error querying today scores: %w", err) } defer rows.Close() scores := make(map[int]float64) for rows.Next() { var projectID int var todayScore float64 if err := rows.Scan(&projectID, &todayScore); err != nil { log.Printf("Error scanning today scores row: %v", err) return nil, fmt.Errorf("error scanning today scores row: %w", err) } scores[projectID] = todayScore } return scores, nil } // getTodayScoresAllUsers получает сумму score всех нод, созданных сегодня для всех пользователей // Возвращает map[project_id]today_score для сегодняшнего дня func (a *App) getTodayScoresAllUsers() (map[int]float64, error) { // Получаем часовой пояс из переменной окружения (по умолчанию 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 timezoneStr = "UTC" } // Вычисляем текущую дату в нужном часовом поясе now := time.Now().In(loc) todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) todayEnd := todayStart.Add(24 * time.Hour) query := ` SELECT n.project_id, COALESCE(SUM(n.score), 0) AS today_score FROM nodes n JOIN projects p ON n.project_id = p.id WHERE p.deleted = FALSE AND n.created_date >= $1 AND n.created_date < $2 GROUP BY n.project_id ` rows, err := a.DB.Query(query, todayStart, todayEnd) if err != nil { log.Printf("Error querying today scores for all users: %v", err) return nil, fmt.Errorf("error querying today scores for all users: %w", err) } defer rows.Close() scores := make(map[int]float64) for rows.Next() { var projectID int var todayScore float64 if err := rows.Scan(&projectID, &todayScore); err != nil { log.Printf("Error scanning today scores row: %v", err) return nil, fmt.Errorf("error scanning today scores row: %w", err) } scores[projectID] = todayScore } 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) { // Получаем данные текущей недели для всех пользователей currentWeekScores, err := a.getCurrentWeekScoresAllUsers() if err != nil { log.Printf("Error getting current week scores: %v", err) return nil, fmt.Errorf("error getting current week scores: %w", err) } // Получаем сегодняшние приросты для всех пользователей todayScores, err := a.getTodayScoresAllUsers() if err != nil { log.Printf("Error getting today scores: %v", err) return nil, fmt.Errorf("error getting today 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, wg.min_goal_score, wg.max_goal_score, wg.priority AS priority, p.color FROM projects p LEFT JOIN weekly_goals wg ON wg.project_id = p.id AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER LEFT JOIN weekly_report_mv wr ON p.id = wr.project_id AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week WHERE p.deleted = FALSE ORDER BY total_score DESC ` rows, err := a.DB.Query(query) if err != nil { log.Printf("Error querying weekly stats: %v", err) return nil, fmt.Errorf("error querying weekly stats: %w", err) } defer rows.Close() projects := make([]WeeklyProjectStats, 0) // Группы для расчета среднего по priority groups := make(map[int][]float64) for rows.Next() { var project WeeklyProjectStats var projectID int var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &projectID, &project.ProjectName, &project.TotalScore, &minGoalScore, &maxGoalScore, &priority, ) if err != nil { log.Printf("Error scanning weekly stats row: %v", err) return nil, fmt.Errorf("error scanning weekly stats row: %w", err) } project.ProjectID = projectID // Объединяем данные: если есть данные текущей недели, используем их вместо MV if currentWeekScore, exists := currentWeekScores[projectID]; exists { project.TotalScore = currentWeekScore } // Добавляем сегодняшний прирост if todayScore, exists := todayScores[projectID]; exists && todayScore != 0 { project.TodayChange = &todayScore } if minGoalScore.Valid { project.MinGoalScore = minGoalScore.Float64 } else { project.MinGoalScore = 0 } if maxGoalScore.Valid { maxGoalVal := maxGoalScore.Float64 project.MaxGoalScore = &maxGoalVal } var priorityVal int if priority.Valid { priorityVal = int(priority.Int64) project.Priority = &priorityVal } // Расчет calculated_score по формуле из n8n totalScore := project.TotalScore minGoalScoreVal := project.MinGoalScore var maxGoalScoreVal float64 if project.MaxGoalScore != nil { maxGoalScoreVal = *project.MaxGoalScore } // Параметры бонуса в зависимости от priority var extraBonusLimit float64 = 20 if priorityVal == 1 { extraBonusLimit = 50 } else if priorityVal == 2 { extraBonusLimit = 35 } // Расчет calculated_score по логике фронтенда // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета var resultScore float64 if minGoalScoreVal <= 0 { // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) resultScore = 0 } else if totalScore < minGoalScoreVal { // До достижения minGoal растем линейно от 0 до 100% resultScore = (totalScore / minGoalScoreVal) * 100.0 } else { // Достигнут minGoal - базовый прогресс = 100% baseProgress := 100.0 // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс if maxGoalScoreVal > minGoalScoreVal { extraRange := maxGoalScoreVal - minGoalScoreVal excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal extraRatio := min(1.0, max(0.0, excess/extraRange)) extraProgress := extraRatio * extraBonusLimit resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) } else { // Если maxGoal не задан или некорректен, просто 100% resultScore = baseProgress } } project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета // Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения if minGoalScoreVal > 0 { if _, exists := groups[priorityVal]; !exists { groups[priorityVal] = make([]float64, 0) } groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) } projects = append(projects, project) } // Вычисляем проценты для каждой группы groupsProgress := calculateGroupsProgress(groups) // Вычисляем общий процент выполнения total := calculateOverallProgress(groupsProgress, groups) response := WeeklyStatsResponse{ Total: total, GroupProgress1: groupsProgress.Group1, GroupProgress2: groupsProgress.Group2, GroupProgress0: groupsProgress.Group0, Projects: projects, } return &response, nil } // getWeeklyStatsDataForUser получает данные о проектах для конкретного пользователя func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error) { // Получаем данные текущей недели напрямую из nodes currentWeekScores, err := a.getCurrentWeekScores(userID) if err != nil { log.Printf("Error getting current week scores: %v", err) return nil, fmt.Errorf("error getting current week scores: %w", err) } // Получаем сегодняшние приросты todayScores, err := a.getTodayScores(userID) if err != nil { log.Printf("Error getting today scores: %v", err) return nil, fmt.Errorf("error getting today 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, wg.max_goal_score, wg.priority AS priority, p.color FROM projects p LEFT JOIN weekly_goals wg ON wg.project_id = p.id AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER LEFT JOIN weekly_report_mv wr ON p.id = wr.project_id AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week WHERE p.deleted = FALSE AND p.user_id = $1 ORDER BY total_score DESC ` rows, err := a.DB.Query(query, userID) if err != nil { return nil, fmt.Errorf("error querying weekly stats: %w", err) } defer rows.Close() projects := make([]WeeklyProjectStats, 0) groups := make(map[int][]float64) for rows.Next() { var project WeeklyProjectStats var projectID int var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &projectID, &project.ProjectName, &project.TotalScore, &minGoalScore, &maxGoalScore, &priority, &project.Color, ) if err != nil { return nil, fmt.Errorf("error scanning weekly stats row: %w", err) } project.ProjectID = projectID // Объединяем данные: если есть данные текущей недели, используем их вместо MV if currentWeekScore, exists := currentWeekScores[projectID]; exists { project.TotalScore = currentWeekScore } // Добавляем сегодняшний прирост if todayScore, exists := todayScores[projectID]; exists && todayScore != 0 { project.TodayChange = &todayScore } if minGoalScore.Valid { project.MinGoalScore = minGoalScore.Float64 } else { project.MinGoalScore = 0 } if maxGoalScore.Valid { maxGoalVal := maxGoalScore.Float64 project.MaxGoalScore = &maxGoalVal } var priorityVal int if priority.Valid { priorityVal = int(priority.Int64) project.Priority = &priorityVal } // Расчет calculated_score totalScore := project.TotalScore minGoalScoreVal := project.MinGoalScore var maxGoalScoreVal float64 if project.MaxGoalScore != nil { maxGoalScoreVal = *project.MaxGoalScore } // Параметры бонуса в зависимости от priority var extraBonusLimit float64 = 20 if priorityVal == 1 { extraBonusLimit = 50 } else if priorityVal == 2 { extraBonusLimit = 35 } // Расчет calculated_score по логике фронтенда // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета var resultScore float64 if minGoalScoreVal <= 0 { // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) resultScore = 0 } else if totalScore < minGoalScoreVal { // До достижения minGoal растем линейно от 0 до 100% resultScore = (totalScore / minGoalScoreVal) * 100.0 } else { // Достигнут minGoal - базовый прогресс = 100% baseProgress := 100.0 // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс if maxGoalScoreVal > minGoalScoreVal { extraRange := maxGoalScoreVal - minGoalScoreVal excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal extraRatio := min(1.0, max(0.0, excess/extraRange)) extraProgress := extraRatio * extraBonusLimit resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) } else { // Если maxGoal не задан или некорректен, просто 100% resultScore = baseProgress } } project.CalculatedScore = roundToTwoDecimals(resultScore) projects = append(projects, project) // Группировка для итогового расчета // Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения if minGoalScoreVal > 0 { if _, exists := groups[priorityVal]; !exists { groups[priorityVal] = make([]float64, 0) } groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) } } // Вычисляем проценты для каждой группы groupsProgress := calculateGroupsProgress(groups) // Вычисляем общий процент выполнения total := calculateOverallProgress(groupsProgress, groups) // Загружаем желания пользователя allWishes, err := a.getWishlistItemsWithConditions(userID, false) if err != nil { log.Printf("Error getting wishlist items for weekly stats: %v", err) allWishes = []WishlistItem{} } // Создаём map projectId -> minGoalScore для расчёта dailyScore projectMinGoalScores := make(map[int]float64) for _, p := range projects { projectMinGoalScores[p.ProjectID] = p.MinGoalScore } // Фильтруем желания для экрана прогресса недели wishes := a.filterWishesForWeekProgress(allWishes, projectMinGoalScores) confirmed, err := a.isPrioritiesConfirmedForUser(userID) if err != nil { log.Printf("Error checking priorities confirmation for user %d: %v", userID, err) confirmed = false } response := WeeklyStatsResponse{ Total: total, GroupProgress1: groupsProgress.Group1, GroupProgress2: groupsProgress.Group2, GroupProgress0: groupsProgress.Group0, Projects: projects, Wishes: wishes, PrioritiesConfirmed: confirmed, } return &response, nil } // formatDailyReport форматирует данные проектов в сообщение для Telegram // Формат аналогичен JS коду из n8n func (a *App) formatDailyReport(data *WeeklyStatsResponse) string { if data == nil || len(data.Projects) == 0 { return "" } // Заголовок сообщения markdownMessage := "*📈 Отчет:*\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) } // Форматирование текста целей // Цели не отображаются пока пользователь не подтвердил приоритеты на этой неделе goalText := "" if data.PrioritiesConfirmed && !math.IsNaN(minGoal) { if hasMaxGoal && !math.IsNaN(maxGoal) { goalText = fmt.Sprintf(" (Цель: %.1f–%.1f)", minGoal, maxGoal) } else { goalText = fmt.Sprintf(" (Цель: мин. %.1f)", minGoal) } } // Собираем строку: Проект: +Score (Цели) markdownMessage += fmt.Sprintf("*%s*: %s%s\n", projectName, scoreFormatted, goalText) } // Выводим итоговый total из корня JSON if data.Total != nil { markdownMessage += "\n---\n" markdownMessage += fmt.Sprintf("*Общее выполнение целей*: %.1f%%", *data.Total) } return markdownMessage } // sendDailyReport отправляет персональные ежедневные отчеты всем пользователям func (a *App) sendDailyReport() error { log.Printf("Scheduled task: Sending daily reports") userIDs, err := a.getAllUsersWithTelegram() if err != nil { return fmt.Errorf("error getting users: %w", err) } if len(userIDs) == 0 { log.Printf("No users with Telegram connected, skipping daily report") return nil } for _, userID := range userIDs { data, err := a.getWeeklyStatsDataForUser(userID) if err != nil { log.Printf("Error getting data for user %d: %v", userID, err) continue } message := a.formatDailyReport(data) if message == "" { continue } if err := a.sendTelegramMessageToUser(userID, message); err != nil { log.Printf("Error sending daily report to user %d: %v", userID, err) } else { log.Printf("Daily report sent to user %d", userID) } } return nil } // startDailyReportScheduler запускает планировщик для ежедневного отчета // каждый день в 23:59 в указанном часовом поясе func (a *App) startDailyReportScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") log.Printf("Loading timezone for daily report scheduler: '%s'", timezoneStr) // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'") loc = time.UTC timezoneStr = "UTC" } else { log.Printf("Daily report scheduler timezone set to: %s", timezoneStr) } // Логируем текущее время в указанном часовом поясе для проверки now := time.Now().In(loc) log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) log.Printf("Next daily report will be sent at: 23:59 %s (cron: '59 23 * * *')", timezoneStr) // Создаем планировщик с указанным часовым поясом c := cron.New(cron.WithLocation(loc)) // Добавляем задачу: каждый день в 23:59 // Cron выражение: "59 23 * * *" означает: минута=59, час=23, любой день месяца, любой месяц, любой день недели _, err = c.AddFunc("59 23 * * *", func() { now := time.Now().In(loc) log.Printf("Scheduled task: Sending daily report (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) if err := a.sendDailyReport(); err != nil { log.Printf("Error in scheduled daily report: %v", err) } }) if err != nil { log.Printf("Error adding cron job for daily report: %v", err) return } // Запускаем планировщик c.Start() log.Printf("Daily report scheduler started: every day at 23:59 %s", timezoneStr) // Планировщик будет работать в фоновом режиме } // startFitbitSyncScheduler запускает планировщик для синхронизации данных Fitbit каждые 4 часа func (a *App) startFitbitSyncScheduler() { // Создаем планировщик в UTC (синхронизация не зависит от часового пояса пользователя) c := cron.New(cron.WithLocation(time.UTC)) // Добавляем задачу: каждые 4 часа // Cron выражение: "0 */4 * * *" означает: минута=0, каждый 4-й час, любой день месяца, любой месяц, любой день недели _, err := c.AddFunc("0 */4 * * *", func() { log.Printf("Scheduled task: Syncing Fitbit data for all users") if err := a.syncFitbitDataForAllUsers(); err != nil { log.Printf("Error in scheduled Fitbit sync: %v", err) } }) if err != nil { log.Printf("Error adding cron job for Fitbit sync: %v", err) return } // Запускаем планировщик c.Start() log.Printf("Fitbit sync scheduler started: every 4 hours") // Планировщик будет работать в фоновом режиме } // startFitbitDailySyncScheduler запускает обязательную синхронизацию Fitbit каждый день в 23:50 (часовой пояс из TIMEZONE) func (a *App) startFitbitDailySyncScheduler() { timezoneStr := getEnv("TIMEZONE", "UTC") log.Printf("Loading timezone for Fitbit daily sync scheduler: '%s'", timezoneStr) loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) loc = time.UTC timezoneStr = "UTC" } else { log.Printf("Fitbit daily sync scheduler timezone set to: %s", timezoneStr) } now := time.Now().In(loc) log.Printf("Current time in Fitbit daily sync timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) log.Printf("Next mandatory Fitbit sync will be at: 23:50 %s (cron: '50 23 * * *')", timezoneStr) c := cron.New(cron.WithLocation(loc)) _, err = c.AddFunc("50 23 * * *", func() { now := time.Now().In(loc) log.Printf("Scheduled task: Mandatory Fitbit sync at 23:50 (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) if err := a.syncFitbitDataForAllUsers(); err != nil { log.Printf("Error in mandatory Fitbit sync at 23:50: %v", err) } else { log.Printf("Mandatory Fitbit sync at 23:50 completed successfully") } }) if err != nil { log.Printf("Error adding cron job for Fitbit daily sync at 23:50: %v", err) return } c.Start() log.Printf("Fitbit daily sync scheduler started: every day at 23:50 %s", timezoneStr) } // syncFitbitDataForAllUsers синхронизирует данные Fitbit для всех подключенных пользователей func (a *App) syncFitbitDataForAllUsers() error { rows, err := a.DB.Query(` SELECT user_id FROM fitbit_integrations WHERE access_token IS NOT NULL `) if err != nil { return fmt.Errorf("failed to get users: %w", err) } defer rows.Close() var userIDs []int for rows.Next() { var userID int if err := rows.Scan(&userID); err != nil { log.Printf("Error scanning user_id: %v", err) continue } userIDs = append(userIDs, userID) } if err := rows.Err(); err != nil { return fmt.Errorf("error iterating users: %w", err) } log.Printf("Syncing Fitbit data for %d users", len(userIDs)) // Синхронизируем данные за сегодня для каждого пользователя (в настроенном часовом поясе) timezoneStr := getEnv("TIMEZONE", "UTC") loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC for Fitbit sync.", timezoneStr, err) loc = time.UTC } today := time.Now().In(loc) for _, userID := range userIDs { if err := a.syncFitbitData(userID, today); err != nil { log.Printf("Failed to sync Fitbit data for user_id=%d: %v", userID, err) // Продолжаем синхронизацию для остальных пользователей continue } } return nil } // startEndOfDayTaskScheduler запускает планировщик для автовыполнения задач в конце дня // каждый день в 23:55 в указанном часовом поясе func (a *App) startEndOfDayTaskScheduler() { // Получаем часовой пояс из переменной окружения (по умолчанию UTC) timezoneStr := getEnv("TIMEZONE", "UTC") log.Printf("Loading timezone for end of day task scheduler: '%s'", timezoneStr) // Загружаем часовой пояс loc, err := time.LoadLocation(timezoneStr) if err != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err) log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'") loc = time.UTC timezoneStr = "UTC" } else { log.Printf("End of day task scheduler timezone set to: %s", timezoneStr) } // Логируем текущее время в указанном часовом поясе для проверки now := time.Now().In(loc) log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) log.Printf("Next end of day task execution will be at: 23:55 %s (cron: '55 23 * * *')", timezoneStr) // Создаем планировщик с указанным часовым поясом c := cron.New(cron.WithLocation(loc)) // Добавляем задачу: каждый день в 23:55 // Cron выражение: "55 23 * * *" означает: минута=55, час=23, любой день месяца, любой месяц, любой день недели _, err = c.AddFunc("55 23 * * *", func() { now := time.Now().In(loc) log.Printf("Scheduled task: Executing end of day tasks (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST")) // Находим все задачи с auto_complete = true rows, err := a.DB.Query(` SELECT task_id, user_id, progression_value FROM task_drafts WHERE auto_complete = TRUE `) if err != nil { log.Printf("Error querying tasks for end of day execution: %v", err) return } defer rows.Close() tasksToExecute := make([]struct { TaskID int UserID int ProgressionValue *float64 }, 0) for rows.Next() { var taskID, userID int var progressionValue sql.NullFloat64 if err := rows.Scan(&taskID, &userID, &progressionValue); err != nil { log.Printf("Error scanning task for end of day execution: %v", err) continue } var progValue *float64 if progressionValue.Valid { progValue = &progressionValue.Float64 } tasksToExecute = append(tasksToExecute, struct { TaskID int UserID int ProgressionValue *float64 }{TaskID: taskID, UserID: userID, ProgressionValue: progValue}) } // Для каждой задачи загружаем подзадачи из драфта и выполняем for _, taskInfo := range tasksToExecute { // Загружаем подзадачи из драфта subtaskRows, err := a.DB.Query(` SELECT subtask_id FROM task_draft_subtasks WHERE task_draft_id = (SELECT id FROM task_drafts WHERE task_id = $1) `, taskInfo.TaskID) childrenTaskIDs := make([]int, 0) if err == nil { defer subtaskRows.Close() for subtaskRows.Next() { var subtaskID int if err := subtaskRows.Scan(&subtaskID); err == nil { childrenTaskIDs = append(childrenTaskIDs, subtaskID) } } } else if err != sql.ErrNoRows { log.Printf("Error loading subtasks for task %d: %v", taskInfo.TaskID, err) } // Формируем CompleteTaskRequest из данных драфта req := CompleteTaskRequest{ Value: taskInfo.ProgressionValue, ChildrenTaskIDs: childrenTaskIDs, } // Вызываем executeTask - она сама удалит драфт перед выполнением err = a.executeTask(taskInfo.TaskID, taskInfo.UserID, req) if err != nil { log.Printf("Error executing task %d at end of day: %v", taskInfo.TaskID, err) } else { log.Printf("Task %d executed successfully at end of day", taskInfo.TaskID) } } }) if err != nil { log.Printf("Error adding cron job for end of day tasks: %v", err) return } // Запускаем планировщик c.Start() log.Printf("End of day task scheduler started: every day at 23:55 %s", timezoneStr) // Планировщик будет работать в фоновом режиме } // readVersion читает версию из файла VERSION func readVersion() string { // Пробуем разные пути к файлу VERSION paths := []string{ "/app/VERSION", // В Docker контейнере "../VERSION", // При запуске из play-life-backend/ "../../VERSION", // Альтернативный путь "VERSION", // Текущая директория } for _, path := range paths { data, err := os.ReadFile(path) if err == nil { version := strings.TrimSpace(string(data)) if version != "" { return version } } } return "unknown" } func main() { // Читаем версию приложения version := readVersion() log.Printf("========================================") log.Printf("Play Life Backend v%s", version) log.Printf("========================================") // Загружаем переменные окружения из .env файла (если существует) // Сначала пробуем загрузить из корня проекта, затем из текущей директории // Игнорируем ошибку, если файл не найден godotenv.Load("../.env") // Пробуем корневой .env godotenv.Load(".env") // Пробуем локальный .env dbHost := getEnv("DB_HOST", "localhost") dbPort := getEnv("DB_PORT", "5432") dbUser := getEnv("DB_USER", "playeng") dbPassword := getEnv("DB_PASSWORD", "playeng") dbName := getEnv("DB_NAME", "playeng") // Логируем параметры подключения к БД (без пароля) log.Printf("Database connection parameters: host=%s port=%s user=%s dbname=%s", dbHost, dbPort, dbUser, dbName) timezoneStr := getEnv("TIMEZONE", "UTC") dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable options='-c timezone=%s'", dbHost, dbPort, dbUser, dbPassword, dbName, timezoneStr) var db *sql.DB var err error // Retry connection for i := 0; i < 10; i++ { db, err = sql.Open("postgres", dsn) if err == nil { err = db.Ping() if err == nil { break } } if i < 9 { time.Sleep(2 * time.Second) } } if err != nil { log.Fatal("Failed to connect to database:", err) } log.Printf("Successfully connected to database: %s@%s:%s/%s", dbUser, dbHost, dbPort, dbName) defer db.Close() // Telegram бот теперь загружается из БД при необходимости // Webhook будет настроен автоматически при сохранении bot token через UI // JWT secret from env or generate random jwtSecret := getEnv("JWT_SECRET", "") if jwtSecret == "" { // Generate random secret if not provided (not recommended for production) b := make([]byte, 32) rand.Read(b) jwtSecret = base64.StdEncoding.EncodeToString(b) log.Printf("WARNING: JWT_SECRET not set, using randomly generated secret. Set JWT_SECRET env var for production.") } app := &App{ DB: db, lastWebhookTime: make(map[int]time.Time), telegramBot: nil, telegramBotUsername: "", jwtSecret: []byte(jwtSecret), } // Инициализация Telegram бота из .env telegramBotToken := getEnv("TELEGRAM_BOT_TOKEN", "") if telegramBotToken != "" { bot, err := tgbotapi.NewBotAPI(telegramBotToken) if err != nil { log.Printf("WARNING: Failed to initialize Telegram bot: %v", err) } else { app.telegramBot = bot log.Printf("Telegram bot initialized successfully") // Получаем username бота через getMe botInfo, err := bot.GetMe() if err != nil { log.Printf("WARNING: Failed to get bot info via getMe(): %v", err) } else { app.telegramBotUsername = botInfo.UserName log.Printf("Telegram bot username: @%s", app.telegramBotUsername) } // Настраиваем webhook для единого бота webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "") if webhookBaseURL != "" { webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram" log.Printf("Setting up Telegram webhook: URL=%s", webhookURL) if err := setupTelegramWebhook(telegramBotToken, webhookURL); err != nil { log.Printf("WARNING: Failed to setup Telegram webhook: %v", err) } else { log.Printf("SUCCESS: Telegram webhook configured: %s", webhookURL) } } else { log.Printf("WEBHOOK_BASE_URL not set. Webhook will not be configured.") } } } else { log.Printf("WARNING: TELEGRAM_BOT_TOKEN not set in environment") } // Apply database migrations if err := app.runMigrations(); err != nil { log.Fatal("Failed to apply database migrations:", err) } log.Println("Database migrations applied successfully") // Запускаем планировщик для автоматической фиксации целей на неделю app.startWeeklyGoalsScheduler() // Запускаем планировщик для ежедневного отчета в 23:59 app.startDailyReportScheduler() // Запускаем планировщик для автовыполнения задач в конце дня в 23:55 app.startEndOfDayTaskScheduler() // Запускаем планировщик синхронизации Fitbit каждые 4 часа app.startFitbitSyncScheduler() // Обязательная синхронизация Fitbit каждый день в 23:50 (перед автовыполнением задач в 23:55) app.startFitbitDailySyncScheduler() 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 (HTML is public, but API calls require auth) // Note: We serve HTML without auth check, but JavaScript will check auth and API calls are protected r.HandleFunc("/admin", app.adminHandler).Methods("GET") r.HandleFunc("/admin.html", app.adminHandler).Methods("GET") // Admin API routes (require authentication and admin privileges) adminAPIRoutes := r.PathPrefix("/").Subrouter() adminAPIRoutes.Use(app.authMiddleware) adminAPIRoutes.Use(app.adminMiddleware) adminAPIRoutes.HandleFunc("/weekly_goals/setup", app.weeklyGoalsSetupHandler).Methods("POST", "OPTIONS") adminAPIRoutes.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS") adminAPIRoutes.HandleFunc("/project_score_sample_mv/refresh", app.projectScoreSampleMvRefreshHandler).Methods("POST", "OPTIONS") // 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") protected.HandleFunc("/api/words/{id}", app.deleteWordHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/words/{id}/reset-progress", app.resetWordProgressHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/test/words", app.getTestWordsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/test/progress", app.updateTestProgressHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/dictionaries", app.getDictionariesHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/dictionaries", app.addDictionaryHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/dictionaries/{id}", app.updateDictionaryHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/dictionaries/{id}", app.deleteDictionaryHandler).Methods("DELETE", "OPTIONS") // Configs protected.HandleFunc("/api/configs", app.getConfigsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/configs", app.addConfigHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/configs/{id}", app.updateConfigHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/configs/{id}", app.deleteConfigHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/configs/{id}/dictionaries", app.getConfigDictionariesHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/test-configs-and-dictionaries", app.getTestConfigsAndDictionariesHandler).Methods("GET", "OPTIONS") // Projects & stats protected.HandleFunc("/api/weekly-stats", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/playlife-feed", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") // Note: /weekly_goals/setup, /daily-report/trigger moved to adminAPIRoutes protected.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/priorities/confirm", app.confirmPrioritiesHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/color", app.setProjectColorHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/project/create", app.createProjectHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/today-entries", app.getTodayEntriesHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/entries/{id}", app.deleteEntryHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/entries/{id}", app.updateEntryHandler).Methods("PUT", "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") // Fitbit OAuth endpoints protected.HandleFunc("/api/integrations/fitbit/oauth/connect", app.fitbitOAuthConnectHandler).Methods("GET") r.HandleFunc("/api/integrations/fitbit/oauth/callback", app.fitbitOAuthCallbackHandler).Methods("GET") // Публичный! protected.HandleFunc("/api/integrations/fitbit/status", app.getFitbitStatusHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/disconnect", app.fitbitDisconnectHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/bindings", app.updateFitbitBindingsHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/bindings/steps", app.updateFitbitStepsBindingsHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/bindings/floors", app.updateFitbitFloorsBindingsHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/sync", app.fitbitSyncHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/integrations/fitbit/stats", app.getFitbitStatsHandler).Methods("GET", "OPTIONS") // Tasks protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS") // Специфичные роуты должны быть ПЕРЕД общим роутом /api/tasks/{id} 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") protected.HandleFunc("/api/tasks/{id}/draft", app.saveTaskDraftHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/tasks/{id}/complete-at-end-of-day", app.completeTaskAtEndOfDayHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tasks/{id}", app.getTaskDetailHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tasks/{id}", app.updateTaskHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/tasks/{id}", app.deleteTaskHandler).Methods("DELETE", "OPTIONS") // 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") protected.HandleFunc("/api/wishlist/calculate-weeks", app.calculateWeeksHandler).Methods("POST", "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") protected.HandleFunc("/api/wishlist/boards/{id}", app.getBoardHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/boards/{id}", app.updateBoardHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/wishlist/boards/{id}", app.deleteBoardHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/wishlist/boards/{id}/regenerate-invite", app.regenerateBoardInviteHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/boards/{id}/members", app.getBoardMembersHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/boards/{id}/members/{userId}", app.removeBoardMemberHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/wishlist/boards/{id}/leave", app.leaveBoardHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.getBoardItemsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.createBoardItemHandler).Methods("POST", "OPTIONS") 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") // Shopping Boards (Товары) protected.HandleFunc("/api/shopping/boards", app.getShoppingBoardsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/boards", app.createShoppingBoardHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{id}", app.getShoppingBoardHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{id}", app.updateShoppingBoardHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{id}", app.deleteShoppingBoardHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{id}/regenerate-invite", app.regenerateShoppingBoardInviteHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{id}/members", app.getShoppingBoardMembersHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{id}/members/{userId}", app.removeShoppingBoardMemberHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{id}/leave", app.leaveShoppingBoardHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.getShoppingItemsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/boards/{boardId}/items", app.createShoppingItemHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/shopping/items/{id}", app.getShoppingItemHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/items/{id}", app.updateShoppingItemHandler).Methods("PUT", "OPTIONS") protected.HandleFunc("/api/shopping/items/{id}", app.deleteShoppingItemHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/shopping/items/{id}/complete", app.completeShoppingItemHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/shopping/items/{id}/postpone", app.postponeShoppingItemHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/shopping/items/{id}/history", app.getShoppingItemHistoryHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/history/{id}", app.deleteShoppingItemHistoryHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/shopping/groups", app.getShoppingGroupSuggestionsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/invite/{token}", app.getShoppingBoardInviteInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/shopping/invite/{token}/join", app.joinShoppingBoardHandler).Methods("POST", "OPTIONS") // Purchase tasks protected.HandleFunc("/api/purchase/boards-info", app.getPurchaseBoardsInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/purchase/items/{purchaseConfigId}", app.getPurchaseItemsHandler).Methods("GET", "OPTIONS") // Tracking protected.HandleFunc("/api/tracking/stats", app.getTrackingStatsHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tracking/invite", app.createTrackingInviteHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tracking/invite/{token}", app.getTrackingInviteInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tracking/invite/{token}/accept", app.acceptTrackingInviteHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/tracking/access", app.getTrackingAccessHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/tracking/trackers/{id}", app.deleteTrackingTrackerHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/tracking/tracked/{id}", app.deleteTrackingTrackedHandler).Methods("DELETE", "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") protected.HandleFunc("/api/wishlist/{id}", app.deleteWishlistHandler).Methods("DELETE", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}/image", app.uploadWishlistImageHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}/image", app.deleteWishlistImageHandler).Methods("DELETE", "OPTIONS") 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}/reject", app.rejectWishlistHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}/copy", app.copyWishlistHandler).Methods("POST", "OPTIONS") // Group suggestions protected.HandleFunc("/api/group-suggestions", app.getGroupSuggestionsHandler).Methods("GET", "OPTIONS") // Admin operations protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") port := getEnv("PORT", "8080") log.Printf("Server starting on port %s", port) log.Printf("Registered public routes: /api/auth/register, /api/auth/login, /api/auth/refresh, webhooks") log.Printf("All other routes require authentication via Bearer token") log.Printf("Admin panel available at: http://localhost:%s/admin.html", port) log.Fatal(http.ListenAndServe(":"+port, r)) } func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value } return defaultValue } // getMapKeys возвращает список ключей из map func getMapKeys(m map[string]interface{}) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } return keys } // setupTelegramWebhook настраивает webhook для Telegram бота func setupTelegramWebhook(botToken, webhookURL string) error { apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken) log.Printf("Setting up Telegram webhook: apiURL=%s, webhookURL=%s", apiURL, webhookURL) payload := map[string]string{ "url": webhookURL, } jsonData, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal webhook payload: %w", err) } // Создаем HTTP клиент с таймаутом client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Post(apiURL, "application/json", bytes.NewBuffer(jsonData)) if err != nil { log.Printf("ERROR: Failed to send webhook setup request: %v", err) return fmt.Errorf("failed to send webhook setup request: %w", err) } defer resp.Body.Close() bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response body: %w", err) } log.Printf("Telegram API response: status=%d, body=%s", resp.StatusCode, string(bodyBytes)) if resp.StatusCode != http.StatusOK { return fmt.Errorf("telegram API returned status %d: %s", resp.StatusCode, string(bodyBytes)) } // Декодируем из уже прочитанных байтов var result map[string]interface{} if err := json.Unmarshal(bodyBytes, &result); err != nil { return fmt.Errorf("failed to decode response: %w", err) } if ok, _ := result["ok"].(bool); !ok { description, _ := result["description"].(string) return fmt.Errorf("telegram API returned error: %s", description) } return nil } // Вспомогательные функции для расчетов func min(a, b float64) float64 { if a < b { return a } return b } func max(a, b float64) float64 { if a > b { return a } return b } func roundToTwoDecimals(val float64) float64 { return float64(int(val*100+0.5)) / 100.0 } func roundToFourDecimals(val float64) float64 { return float64(int(val*10000+0.5)) / 10000.0 } // calculateGroupsProgress вычисляет проценты выполнения для каждой группы приоритетов // groups - карта приоритетов к спискам calculatedScore проектов // Возвращает структуру GroupsProgress с процентами для каждой группы // Если какая-то группа отсутствует, она считается как 100% // min_goal = 100%, max_goal = 150%/135%/120% в зависимости от приоритета 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% avg = 100.0 } else { // Для всех групп (1, 2, 0) — обычное среднее: 100% только при среднем 100% sum := 0.0 for _, score := range scores { sum += score } avg = sum / float64(len(scores)) } // Для остальных приоритетов (не 1 и не 2) применяем буст × 1.2, но не более 120% if priorityVal != 1 && priorityVal != 2 { boosted := avg * 1.2 if boosted > 120.0 { boosted = 120.0 } avg = boosted } // Сохраняем результат в соответствующее поле avgRounded := roundToFourDecimals(avg) switch priorityVal { case 1: result.Group1 = &avgRounded case 2: result.Group2 = &avgRounded case 0: result.Group0 = &avgRounded } } return result } // calculateOverallProgress вычисляет общий процент выполнения на основе процентов групп // groupsProgress - структура с процентами для каждой группы приоритетов // groups - карта приоритетов к спискам calculatedScore проектов (используется для точного расчета) // Возвращает указатель на float64 с общим процентом выполнения // Вычисляет среднее между группами (min_goal = 100%, max_goal = 150%/135%/120%) func calculateOverallProgress(groupsProgress GroupsProgress, groups map[int][]float64) *float64 { // Собираем проценты по группам var groupScores []float64 // Добавляем проценты только тех групп, которые существуют (имеют проекты) if groupsProgress.Group1 != nil { groupScores = append(groupScores, *groupsProgress.Group1) } if groupsProgress.Group2 != nil { groupScores = append(groupScores, *groupsProgress.Group2) } if groupsProgress.Group0 != nil { groupScores = append(groupScores, *groupsProgress.Group0) } // Если нет групп с проектами, возвращаем 0 if len(groupScores) == 0 { zero := 0.0 return &zero } // Вычисляем среднее между группами var sum float64 for _, score := range groupScores { sum += score } overallProgress := sum / float64(len(groupScores)) overallProgressRounded := roundToFourDecimals(overallProgress) total := &overallProgressRounded return total } // TelegramIntegration представляет запись из таблицы telegram_integrations type TelegramIntegration struct { ID int `json:"id"` UserID int `json:"user_id"` TelegramUserID *int64 `json:"telegram_user_id,omitempty"` ChatID *string `json:"chat_id,omitempty"` StartToken *string `json:"start_token,omitempty"` CreatedAt *time.Time `json:"created_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } // TodoistIntegration представляет запись из таблицы todoist_integrations type TodoistIntegration struct { ID int `json:"id"` UserID int `json:"user_id"` TodoistUserID *int64 `json:"todoist_user_id,omitempty"` TodoistEmail *string `json:"todoist_email,omitempty"` AccessToken *string `json:"-"` // Не отдавать в JSON! CreatedAt *time.Time `json:"created_at,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty"` } // getTelegramIntegration получает telegram интеграцию из БД // getTelegramIntegrationForUser gets telegram integration for specific user func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, error) { var integration TelegramIntegration var telegramUserID sql.NullInt64 var chatID, startToken sql.NullString var createdAt, updatedAt sql.NullTime err := a.DB.QueryRow(` SELECT id, user_id, telegram_user_id, chat_id, start_token, created_at, updated_at FROM telegram_integrations WHERE user_id = $1 LIMIT 1 `, userID).Scan( &integration.ID, &integration.UserID, &telegramUserID, &chatID, &startToken, &createdAt, &updatedAt, ) if err == sql.ErrNoRows { // Создаем новую запись с start_token startTokenValue, err := generateWebhookToken() if err != nil { return nil, fmt.Errorf("failed to generate start token: %w", err) } err = a.DB.QueryRow(` INSERT INTO telegram_integrations (user_id, start_token) VALUES ($1, $2) RETURNING id, user_id, telegram_user_id, chat_id, start_token, created_at, updated_at `, userID, startTokenValue).Scan( &integration.ID, &integration.UserID, &telegramUserID, &chatID, &startToken, &createdAt, &updatedAt, ) if err != nil { return nil, fmt.Errorf("failed to create telegram integration: %w", err) } startToken = sql.NullString{String: startTokenValue, Valid: true} } else if err != nil { return nil, fmt.Errorf("failed to get telegram integration: %w", err) } // Заполняем указатели if telegramUserID.Valid { integration.TelegramUserID = &telegramUserID.Int64 } if chatID.Valid { integration.ChatID = &chatID.String } if startToken.Valid { integration.StartToken = &startToken.String } if createdAt.Valid { integration.CreatedAt = &createdAt.Time } if updatedAt.Valid { integration.UpdatedAt = &updatedAt.Time } return &integration, nil } // sendTelegramMessageToChat - отправляет сообщение в конкретный чат по chat_id func (a *App) sendTelegramMessageToChat(chatID int64, text string) error { if a.telegramBot == nil { return fmt.Errorf("telegram bot not initialized") } telegramText := regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "*$1*") msg := tgbotapi.NewMessage(chatID, telegramText) msg.ParseMode = "Markdown" _, err := a.telegramBot.Send(msg) if err != nil { // Проверяем, не заблокирован ли бот if strings.Contains(err.Error(), "blocked") || strings.Contains(err.Error(), "chat not found") || strings.Contains(err.Error(), "bot was blocked") { // Пользователь заблокировал бота - очищаем данные chatIDStr := strconv.FormatInt(chatID, 10) a.DB.Exec(` UPDATE telegram_integrations SET telegram_user_id = NULL, chat_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE chat_id = $1 `, chatIDStr) log.Printf("User blocked bot, cleared integration for chat_id=%d", chatID) } return err } log.Printf("Message sent to chat_id=%d", chatID) return nil } // sendTelegramMessageToUser - отправляет сообщение пользователю по user_id func (a *App) sendTelegramMessageToUser(userID int, text string) error { var chatID sql.NullString err := a.DB.QueryRow(` SELECT chat_id FROM telegram_integrations WHERE user_id = $1 AND chat_id IS NOT NULL `, userID).Scan(&chatID) if err == sql.ErrNoRows || !chatID.Valid { return fmt.Errorf("telegram not connected for user %d", userID) } if err != nil { return err } chatIDInt, err := strconv.ParseInt(chatID.String, 10, 64) if err != nil { return fmt.Errorf("invalid chat_id format: %w", err) } return a.sendTelegramMessageToChat(chatIDInt, text) } // getAllUsersWithTelegram - получает список всех user_id с подключенным Telegram func (a *App) getAllUsersWithTelegram() ([]int, error) { rows, err := a.DB.Query(` SELECT user_id FROM telegram_integrations WHERE chat_id IS NOT NULL AND telegram_user_id IS NOT NULL `) if err != nil { return nil, err } defer rows.Close() var userIDs []int for rows.Next() { var userID int if err := rows.Scan(&userID); err == nil { userIDs = append(userIDs, userID) } } return userIDs, nil } // utf16OffsetToUTF8 конвертирует UTF-16 offset в UTF-8 byte offset func utf16OffsetToUTF8(text string, utf16Offset int) int { utf16Runes := utf16.Encode([]rune(text)) if utf16Offset >= len(utf16Runes) { return len(text) } // Конвертируем UTF-16 кодовые единицы обратно в UTF-8 байты runes := utf16.Decode(utf16Runes[:utf16Offset]) return len(string(runes)) } // utf16LengthToUTF8 конвертирует UTF-16 length в UTF-8 byte length func utf16LengthToUTF8(text string, utf16Offset, utf16Length int) int { utf16Runes := utf16.Encode([]rune(text)) if utf16Offset+utf16Length > len(utf16Runes) { utf16Length = len(utf16Runes) - utf16Offset } if utf16Length <= 0 { return 0 } // Конвертируем UTF-16 кодовые единицы в UTF-8 байты startRunes := utf16.Decode(utf16Runes[:utf16Offset]) endRunes := utf16.Decode(utf16Runes[:utf16Offset+utf16Length]) startBytes := len(string(startRunes)) endBytes := len(string(endRunes)) return endBytes - startBytes } // processTelegramMessage обрабатывает сообщение из Telegram с использованием entities // Логика отличается от processMessage: использует entities для определения жирного текста // и не отправляет сообщение обратно в Telegram // userID может быть nil, если пользователь не определен func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, userID *int) (*ProcessedEntry, error) { fullText = strings.TrimSpace(fullText) // Регулярное выражение: project+/-score (без **) scoreRegex := regexp.MustCompile(`^([а-яА-ЯёЁ\w]+)([+-])(\d+(?:\.\d+)?)$`) // Массив для хранения извлеченных элементов {project, score} scoreNodes := make([]ProcessedNode, 0) workingText := fullText placeholderIndex := 0 // Находим все элементы, выделенные жирным шрифтом boldEntities := make([]TelegramEntity, 0) for _, entity := range entities { if entity.Type == "bold" { boldEntities = append(boldEntities, entity) } } // Сортируем в ПРЯМОМ порядке (по offset), чтобы гарантировать, что ${0} соответствует первому в тексте sort.Slice(boldEntities, func(i, j int) bool { return boldEntities[i].Offset < boldEntities[j].Offset }) // Массив для хранения данных, которые будут использоваться для замены в обратном порядке type ReplacementData struct { Start int Length int Placeholder string } replacementData := make([]ReplacementData, 0) for _, entity := range boldEntities { // Telegram использует UTF-16 для offset и length, конвертируем в UTF-8 байты start := utf16OffsetToUTF8(fullText, entity.Offset) length := utf16LengthToUTF8(fullText, entity.Offset, entity.Length) // Извлекаем чистый жирный текст if start+length > len(fullText) { continue // Пропускаем некорректные entities } boldText := strings.TrimSpace(fullText[start : start+length]) // Проверяем соответствие формату match := scoreRegex.FindStringSubmatch(boldText) if match != nil && len(match) == 4 { // Создаем элемент node project := match[1] sign := match[2] rawScore, err := strconv.ParseFloat(match[3], 64) if err != nil { log.Printf("Error parsing score: %v", err) continue } score := rawScore if sign == "-" { score = -rawScore } // Добавляем в массив nodes (по порядку) scoreNodes = append(scoreNodes, ProcessedNode{ Project: project, Score: score, }) // Создаем данные для замены replacementData = append(replacementData, ReplacementData{ Start: start, Length: length, Placeholder: fmt.Sprintf("${%d}", placeholderIndex), }) placeholderIndex++ } } // Теперь выполняем замены в ОБРАТНОМ порядке, чтобы offset не "смещались" sort.Slice(replacementData, func(i, j int) bool { return replacementData[i].Start > replacementData[j].Start }) for _, item := range replacementData { // Заменяем сегмент в workingText, используя оригинальные offset и length if item.Start+item.Length <= len(workingText) { workingText = workingText[:item.Start] + item.Placeholder + workingText[item.Start+item.Length:] } } // Удаляем пустые строки и лишние пробелы lines := strings.Split(workingText, "\n") cleanedLines := make([]string, 0) for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed != "" { cleanedLines = append(cleanedLines, trimmed) } } processedText := strings.Join(cleanedLines, "\n") // Используем текущее время в формате ISO 8601 в настроенном часовом поясе 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 } createdDate := time.Now().In(loc).Format(time.RFC3339) // Вставляем данные в БД только если есть nodes if len(scoreNodes) > 0 { err := a.insertMessageData(processedText, createdDate, scoreNodes, userID) if err != nil { log.Printf("Error inserting message data: %v", err) return nil, fmt.Errorf("error inserting data: %w", err) } } else { // Если nodes нет, используем исходный текст для processedText processedText = fullText log.Printf("No nodes found in Telegram message, message will not be saved to database") } // Формируем ответ response := &ProcessedEntry{ Text: processedText, CreatedDate: createdDate, Nodes: scoreNodes, Raw: fullText, Markdown: fullText, // Для Telegram markdown не нужен } // НЕ отправляем сообщение обратно в Telegram (в отличие от processMessage) return response, nil } // processMessage обрабатывает текст сообщения: парсит ноды, сохраняет в БД и отправляет в Telegram func (a *App) processMessage(rawText string, userID *int) (*ProcessedEntry, error) { return a.processMessageInternal(rawText, true, userID) } // processMessageWithoutTelegram обрабатывает текст сообщения: парсит ноды, сохраняет в БД, но НЕ отправляет в Telegram func (a *App) processMessageWithoutTelegram(rawText string, userID *int) (*ProcessedEntry, error) { return a.processMessageInternal(rawText, false, userID) } // processMessageInternal - внутренняя функция обработки сообщения // sendToTelegram определяет, нужно ли отправлять сообщение в Telegram func (a *App) processMessageInternal(rawText string, sendToTelegram bool, userID *int) (*ProcessedEntry, error) { rawText = strings.TrimSpace(rawText) // Регулярное выражение для поиска **[Project][+| -][Score]** regex := regexp.MustCompile(`\*\*(.+?)([+-])([\d.]+)\*\*`) nodes := make([]ProcessedNode, 0) nodeCounter := 0 // Ищем все node и заменяем их в тексте на плейсхолдеры ${0}, ${1} и т.д. processedText := regex.ReplaceAllStringFunc(rawText, func(fullMatch string) string { matches := regex.FindStringSubmatch(fullMatch) if len(matches) != 4 { return fullMatch } projectName := strings.TrimSpace(matches[1]) sign := matches[2] scoreString := matches[3] score, err := strconv.ParseFloat(scoreString, 64) if err != nil { log.Printf("Error parsing score: %v", err) return fullMatch } if sign == "-" { score = -score } // Добавляем данные в массив nodes nodes = append(nodes, ProcessedNode{ Project: projectName, Score: score, }) placeholder := fmt.Sprintf("${%d}", nodeCounter) nodeCounter++ return placeholder }) // Удаляем пустые строки и лишние пробелы lines := strings.Split(processedText, "\n") cleanedLines := make([]string, 0) for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed != "" { cleanedLines = append(cleanedLines, trimmed) } } processedText = strings.Join(cleanedLines, "\n") // Формируем Markdown (Legacy) контент: заменяем ** на * markdownText := strings.ReplaceAll(rawText, "**", "*") // Используем текущее время в настроенном часовом поясе 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 } createdDate := time.Now().In(loc).Format(time.RFC3339) // Вставляем данные в БД только если есть nodes if len(nodes) > 0 { err := a.insertMessageData(processedText, createdDate, nodes, userID) if err != nil { log.Printf("Error inserting message data: %v", err) return nil, fmt.Errorf("error inserting data: %w", err) } } else { // Если nodes нет, используем исходный текст для processedText processedText = rawText if sendToTelegram { log.Printf("No nodes found in text, message will be sent to Telegram but not saved to database") } else { log.Printf("No nodes found in text, message will be ignored (not saved to database and not sent to Telegram)") } } // Формируем ответ response := &ProcessedEntry{ Text: processedText, CreatedDate: createdDate, Nodes: nodes, Raw: rawText, Markdown: markdownText, } // Отправляем дублирующее сообщение в Telegram только если указано if sendToTelegram && userID != nil { if err := a.sendTelegramMessageToUser(*userID, rawText); err != nil { log.Printf("Error sending Telegram message: %v", err) } } return response, nil } func (a *App) messagePostHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) // Get user ID from context (may be nil for webhook) var userIDPtr *int if userID, ok := getUserIDFromContext(r); ok { userIDPtr = &userID } // Парсим входящий запрос - может быть как {body: {text: ...}}, так и {text: ...} var rawReq map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&rawReq); err != nil { log.Printf("Error decoding message post request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Извлекаем text из разных возможных структур var rawText string if body, ok := rawReq["body"].(map[string]interface{}); ok { if text, ok := body["text"].(string); ok { rawText = text } } // Если не нашли в body, пробуем напрямую if rawText == "" { if text, ok := rawReq["text"].(string); ok { rawText = text } } // Проверка на наличие нужного поля if rawText == "" { sendErrorWithCORS(w, "Missing 'text' field in body", http.StatusBadRequest) return } // Обрабатываем сообщение response, err := a.processMessage(rawText, userIDPtr) if err != nil { log.Printf("Error processing message: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } func (a *App) insertMessageData(entryText string, createdDate string, nodes []ProcessedNode, userID *int) error { // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() // 1. UPSERT проектов projectNames := make(map[string]bool) for _, node := range nodes { projectNames[node.Project] = true } // Вставляем проекты for projectName := range projectNames { if userID != nil { // Используем более универсальный подход: проверяем существование и вставляем/обновляем var existingID int err := tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND user_id = $2 AND deleted = FALSE `, projectName, *userID).Scan(&existingID) if err == sql.ErrNoRows { // Проект не существует, создаем новый randomColor := generateRandomProjectColor() _, err = tx.Exec(` INSERT INTO projects (name, deleted, user_id, color) VALUES ($1, FALSE, $2, $3) `, projectName, *userID, randomColor) if err != nil { // Если ошибка из-за уникальности, пробуем обновить существующий _, err = tx.Exec(` UPDATE projects SET deleted = FALSE, user_id = COALESCE(user_id, $2) WHERE name = $1 `, projectName, *userID) if err != nil { return fmt.Errorf("failed to upsert project %s: %w", projectName, err) } } } else if err != nil { return fmt.Errorf("failed to check project %s: %w", projectName, err) } // Проект уже существует, ничего не делаем } else { // Для случая без user_id (legacy) var existingID int err := tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND deleted = FALSE `, projectName).Scan(&existingID) if err == sql.ErrNoRows { // Проект не существует, создаем новый randomColor := generateRandomProjectColor() _, err = tx.Exec(` INSERT INTO projects (name, deleted, color) VALUES ($1, FALSE, $2) `, projectName, randomColor) if err != nil { return fmt.Errorf("failed to insert project %s: %w", projectName, err) } } else if err != nil { return fmt.Errorf("failed to check project %s: %w", projectName, err) } // Проект уже существует, ничего не делаем } } // 2. Вставляем entry var entryID int if userID != nil { err = tx.QueryRow(` INSERT INTO entries (text, created_date, user_id) VALUES ($1, $2, $3) RETURNING id `, entryText, createdDate, *userID).Scan(&entryID) } else { err = tx.QueryRow(` INSERT INTO entries (text, created_date) VALUES ($1, $2) RETURNING id `, entryText, createdDate).Scan(&entryID) } if err != nil { return fmt.Errorf("failed to insert entry: %w", err) } // 3. Вставляем nodes for _, node := range nodes { var projectID int if userID != nil { err = tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND user_id = $2 AND deleted = FALSE `, node.Project, *userID).Scan(&projectID) } else { err = tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND deleted = FALSE `, node.Project).Scan(&projectID) } if err == sql.ErrNoRows { return fmt.Errorf("project %s not found after insert", node.Project) } else if err != nil { return fmt.Errorf("failed to find project %s: %w", node.Project, err) } // Вставляем node с user_id и created_date (денормализация) if userID != nil { _, err = tx.Exec(` 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, 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) } } // MV обновляется только по крону в понедельник в 6:00 утра // Данные текущей недели берутся напрямую из nodes // Коммитим транзакцию 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 ( -- Считаем среднее (average) на основе данных за последние 4 недели, исключая текущую неделю SELECT project_id, AVG(normalized_total_score) AS avg_score FROM ( SELECT project_id, normalized_total_score, report_year, report_week, -- Нумеруем недели от новых к старым ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn FROM weekly_report_mv WHERE -- Исключаем текущую неделю и все будущие недели -- Используем сравнение (year, week) < (current_year, current_week) для корректного исключения (report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER) OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER) ) sub WHERE rn <= 4 -- Берем историю за последние 4 недели, исключая текущую неделю GROUP BY project_id ) INSERT INTO weekly_goals ( project_id, goal_year, goal_week, min_goal_score, max_goal_score, priority, user_id ) SELECT p.id, ci.c_year, ci.c_week, -- Если нет данных (gm.avg_score IS NULL), используем 0 (значение по умолчанию) COALESCE(gm.avg_score, 0) AS min_goal_score, -- Логика max_goal_score в зависимости от приоритета (только если есть данные) CASE WHEN gm.avg_score IS NULL THEN NULL WHEN p.priority = 1 THEN gm.avg_score * 2.0 WHEN p.priority = 2 THEN gm.avg_score * 1.7 ELSE gm.avg_score * 1.4 END AS max_goal_score, p.priority, p.user_id 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, user_id = EXCLUDED.user_id ` _, 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 } // isPrioritiesConfirmedForUser проверяет, подтвердил ли пользователь приоритеты на текущей ISO-неделе func (a *App) isPrioritiesConfirmedForUser(userID int) (bool, error) { timezoneStr := getEnv("TIMEZONE", "UTC") loc, err := time.LoadLocation(timezoneStr) if err != nil { loc = time.UTC } now := time.Now().In(loc) currentYear, currentWeek := now.ISOWeek() var confirmedYear, confirmedWeek int err = a.DB.QueryRow( `SELECT priorities_confirmed_year, priorities_confirmed_week FROM users WHERE id = $1`, userID, ).Scan(&confirmedYear, &confirmedWeek) if err != nil { return false, err } return confirmedYear == currentYear && confirmedWeek == currentWeek, nil } // setPrioritiesConfirmed помечает приоритеты пользователя как подтверждённые на текущей ISO-неделе func (a *App) setPrioritiesConfirmed(userID int) error { timezoneStr := getEnv("TIMEZONE", "UTC") loc, err := time.LoadLocation(timezoneStr) if err != nil { loc = time.UTC } now := time.Now().In(loc) currentYear, currentWeek := now.ISOWeek() _, err = a.DB.Exec( `UPDATE users SET priorities_confirmed_year = $1, priorities_confirmed_week = $2 WHERE id = $3`, currentYear, currentWeek, userID, ) return err } // setupWeeklyGoalsForUser устанавливает цели на текущую неделю для одного пользователя func (a *App) setupWeeklyGoalsForUser(userID int) error { setupQuery := ` WITH current_info AS ( SELECT EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AS c_year, EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week ), goal_metrics AS ( SELECT project_id, AVG(normalized_total_score) AS avg_score FROM ( SELECT project_id, normalized_total_score, report_year, report_week, ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn FROM weekly_report_mv WHERE (report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER) OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER) ) sub WHERE rn <= 4 GROUP BY project_id ) INSERT INTO weekly_goals ( project_id, goal_year, goal_week, min_goal_score, max_goal_score, priority, user_id ) SELECT p.id, ci.c_year, ci.c_week, COALESCE(gm.avg_score, 0) AS min_goal_score, CASE WHEN gm.avg_score IS NULL THEN NULL WHEN p.priority = 1 THEN gm.avg_score * 2.0 WHEN p.priority = 2 THEN gm.avg_score * 1.7 ELSE gm.avg_score * 1.4 END AS max_goal_score, p.priority, p.user_id FROM projects p CROSS JOIN current_info ci LEFT JOIN goal_metrics gm ON p.id = gm.project_id WHERE p.deleted = FALSE AND p.user_id = $1 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, user_id = EXCLUDED.user_id ` _, err := a.DB.Exec(setupQuery, userID) if err != nil { return fmt.Errorf("error setting up weekly goals for user %d: %w", userID, err) } log.Printf("Weekly goals setup completed for user %d", userID) return nil } // getWeeklyGoalsForUser получает цели для конкретного пользователя func (a *App) getWeeklyGoalsForUser(userID int) ([]WeeklyGoalSetup, error) { selectQuery := ` SELECT p.name AS project_name, wg.min_goal_score, wg.max_goal_score FROM weekly_goals wg JOIN projects p ON wg.project_id = p.id WHERE wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AND p.deleted = FALSE AND p.user_id = $1 ORDER BY p.name ` rows, err := a.DB.Query(selectQuery, userID) if err != nil { return nil, fmt.Errorf("error querying weekly goals: %w", err) } defer rows.Close() goals := make([]WeeklyGoalSetup, 0) for rows.Next() { var goal WeeklyGoalSetup var maxGoalScore sql.NullFloat64 err := rows.Scan( &goal.ProjectName, &goal.MinGoalScore, &maxGoalScore, ) if err != nil { log.Printf("Error scanning weekly goal row: %v", err) continue } if maxGoalScore.Valid { goal.MaxGoalScore = maxGoalScore.Float64 } else { goal.MaxGoalScore = math.NaN() } goals = append(goals, goal) } return goals, nil } // sendWeeklyGoalsTelegramMessage отправляет персональные цели всем пользователям func (a *App) sendWeeklyGoalsTelegramMessage() error { userIDs, err := a.getAllUsersWithTelegram() if err != nil { return err } for _, userID := range userIDs { goals, err := a.getWeeklyGoalsForUser(userID) if err != nil { log.Printf("Error getting goals for user %d: %v", userID, err) continue } message := a.formatWeeklyGoalsMessage(goals) if message == "" { continue } if err := a.sendTelegramMessageToUser(userID, message); err != nil { log.Printf("Error sending weekly goals to user %d: %v", userID, err) } } return nil } // formatWeeklyGoalsMessage форматирует список целей в сообщение для Telegram // Формат аналогичен JS коду из n8n func (a *App) formatWeeklyGoalsMessage(goals []WeeklyGoalSetup) string { if len(goals) == 0 { return "" } // Заголовок сообщения: "Цели на неделю" markdownMessage := "*🎯 Цели:*\n\n" // Обработка каждого проекта for _, goal := range goals { // Пропускаем проекты без названия if goal.ProjectName == "" { continue } // Получаем и форматируем цели minGoal := goal.MinGoalScore maxGoal := goal.MaxGoalScore var goalText string // Форматируем текст цели, если они существуют // Проверяем, что minGoal валиден (не NaN) // В JS коде проверяется isNaN, поэтому проверяем только на NaN if !math.IsNaN(minGoal) { minGoalFormatted := fmt.Sprintf("%.2f", minGoal) // Формируем диапазон: [MIN] или [MIN - MAX] // maxGoal должен быть валиден (не NaN) для отображения диапазона if !math.IsNaN(maxGoal) { maxGoalFormatted := fmt.Sprintf("%.2f", maxGoal) // Формат: *Проект*: от 15.00 до 20.00 goalText = fmt.Sprintf(" от %s до %s", minGoalFormatted, maxGoalFormatted) } else { // Формат: *Проект*: мин. 15.00 goalText = fmt.Sprintf(" мин. %s", minGoalFormatted) } } else { // Если minGoal не установлен (NaN), пропускаем вывод цели continue } // Форматирование строки для Markdown (Legacy): *Название*: Цель markdownMessage += fmt.Sprintf("*%s*:%s\n", goal.ProjectName, goalText) } return markdownMessage } func (a *App) weeklyGoalsSetupHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) err := a.setupWeeklyGoals() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Получаем установленные цели для ответа selectQuery := ` SELECT p.name AS project_name, wg.min_goal_score, wg.max_goal_score FROM weekly_goals wg JOIN projects p ON wg.project_id = p.id WHERE wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AND p.deleted = FALSE ORDER BY p.name ` rows, err := a.DB.Query(selectQuery) if err != nil { log.Printf("Error querying weekly goals: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying weekly goals: %v", err), http.StatusInternalServerError) return } defer rows.Close() goals := make([]WeeklyGoalSetup, 0) for rows.Next() { var goal WeeklyGoalSetup var maxGoalScore sql.NullFloat64 err := rows.Scan( &goal.ProjectName, &goal.MinGoalScore, &maxGoalScore, ) if err != nil { log.Printf("Error scanning weekly goal row: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError) return } if maxGoalScore.Valid { goal.MaxGoalScore = maxGoalScore.Float64 } else { goal.MaxGoalScore = 0.0 } goals = append(goals, goal) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(goals) } // dailyReportTriggerHandler обрабатывает запрос на отправку ежедневного отчёта func (a *App) dailyReportTriggerHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) log.Printf("Manual trigger: Sending daily report") err := a.sendDailyReport() if err != nil { log.Printf("Error in manual daily report trigger: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "Daily report sent successfully", }) } // projectScoreSampleMvRefreshHandler refreshes project_score_sample_mv and returns rows for the current user func (a *App) projectScoreSampleMvRefreshHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW project_score_sample_mv") if err != nil { log.Printf("Error refreshing project_score_sample_mv: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error refreshing MV: %v", err), http.StatusInternalServerError) return } userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } rows, err := a.DB.Query(` SELECT project_id, score, entry_message, user_id, created_date FROM project_score_sample_mv WHERE user_id = $1 ORDER BY project_id, score `, userID) if err != nil { log.Printf("Error querying project_score_sample_mv: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying MV: %v", err), http.StatusInternalServerError) return } defer rows.Close() data := make([]ProjectScoreSampleMvRow, 0) for rows.Next() { var row ProjectScoreSampleMvRow var userIDNull sql.NullInt64 err := rows.Scan(&row.ProjectID, &row.Score, &row.EntryMessage, &userIDNull, &row.CreatedDate) if err != nil { log.Printf("Error scanning project_score_sample_mv row: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError) return } if userIDNull.Valid { uid := int(userIDNull.Int64) row.UserID = &uid } data = append(data, row) } if err = rows.Err(); err != nil { log.Printf("Error iterating project_score_sample_mv rows: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error reading data: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } 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, CASE WHEN wg.max_goal_score IS NULL THEN COALESCE(agg.total_score, 0.0000) ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_goal_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 ` if _, err := a.DB.Exec(createMaterializedView); err != nil { log.Printf("Error creating materialized view: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating materialized view: %v", err), http.StatusInternalServerError) return } // Создаем индекс createMVIndex := ` CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week ON weekly_report_mv(project_id, report_year, report_week) ` if _, err := a.DB.Exec(createMVIndex); err != nil { log.Printf("Warning: Failed to create materialized view index: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "message": "Materialized view recreated successfully with ISOYEAR fix", }) } func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } query := ` SELECT id AS project_id, name AS project_name, priority, color FROM projects WHERE deleted = FALSE AND user_id = $1 ORDER BY priority ASC NULLS LAST, project_name ` rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying projects: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying projects: %v", err), http.StatusInternalServerError) return } defer rows.Close() projects := make([]Project, 0) for rows.Next() { var project Project var priority sql.NullInt64 err := rows.Scan( &project.ProjectID, &project.ProjectName, &priority, &project.Color, ) if err != nil { log.Printf("Error scanning project row: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError) return } if priority.Valid { priorityVal := int(priority.Int64) project.Priority = &priorityVal } projects = append(projects, project) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(projects) } func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } _ = userID // Will be used in SQL queries // Читаем тело запроса один раз bodyBytes, err := io.ReadAll(r.Body) if err != nil { log.Printf("Error reading request body: %v", err) sendErrorWithCORS(w, "Error reading request body", http.StatusBadRequest) return } defer r.Body.Close() // Парсим входящий запрос - может быть как {body: [...]}, так и просто массив var projectsToUpdate []ProjectPriorityUpdate // Сначала пробуем декодировать как прямой массив var directArray []interface{} arrayErr := json.Unmarshal(bodyBytes, &directArray) if arrayErr == nil && len(directArray) > 0 { // Успешно декодировали как массив log.Printf("Received direct array format with %d items", len(directArray)) for _, item := range directArray { if itemMap, ok := item.(map[string]interface{}); ok { var project ProjectPriorityUpdate // Извлекаем id if idVal, ok := itemMap["id"].(float64); ok { project.ID = int(idVal) } else if idVal, ok := itemMap["id"].(int); ok { project.ID = idVal } else { log.Printf("Invalid id in request item: %v", itemMap) continue } // Извлекаем priority (может быть null, undefined, или числом) if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil { // Проверяем, не является ли это строкой "null" if strVal, ok := priorityVal.(string); ok && (strVal == "null" || strVal == "NULL") { project.Priority = nil } else if numVal, ok := priorityVal.(float64); ok { priorityInt := int(numVal) project.Priority = &priorityInt } else if numVal, ok := priorityVal.(int); ok { project.Priority = &numVal } else { project.Priority = nil } } else { project.Priority = nil } projectsToUpdate = append(projectsToUpdate, project) } } } // Если не получилось как массив (ошибка декодирования), пробуем как объект с body // НЕ пытаемся декодировать как объект, если массив декодировался успешно (даже если пустой) if len(projectsToUpdate) == 0 && arrayErr != nil { log.Printf("Failed to decode as array (error: %v), trying as object", arrayErr) var rawReq map[string]interface{} if err := json.Unmarshal(bodyBytes, &rawReq); err != nil { log.Printf("Error decoding project priority request as object: %v, body: %s", err, string(bodyBytes)) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Извлекаем массив проектов из body if body, ok := rawReq["body"].([]interface{}); ok { log.Printf("Received body format with %d items", len(body)) for _, item := range body { if itemMap, ok := item.(map[string]interface{}); ok { var project ProjectPriorityUpdate // Извлекаем id if idVal, ok := itemMap["id"].(float64); ok { project.ID = int(idVal) } else if idVal, ok := itemMap["id"].(int); ok { project.ID = idVal } else { log.Printf("Invalid id in request item: %v", itemMap) continue } // Извлекаем priority (может быть null, undefined, или числом) if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil { // Проверяем, не является ли это строкой "null" if strVal, ok := priorityVal.(string); ok && (strVal == "null" || strVal == "NULL") { project.Priority = nil } else if numVal, ok := priorityVal.(float64); ok { priorityInt := int(numVal) project.Priority = &priorityInt } else if numVal, ok := priorityVal.(int); ok { project.Priority = &numVal } else { project.Priority = nil } } else { project.Priority = nil } projectsToUpdate = append(projectsToUpdate, project) } } } } if len(projectsToUpdate) == 0 { log.Printf("No projects to update after parsing. Body was: %s", string(bodyBytes)) sendErrorWithCORS(w, "No projects to update", http.StatusBadRequest) return } log.Printf("Successfully parsed %d projects to update", len(projectsToUpdate)) // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) return } defer tx.Rollback() // Обновляем приоритеты для каждого проекта for _, project := range projectsToUpdate { if project.Priority == nil { _, err = tx.Exec(` UPDATE projects SET priority = NULL WHERE id = $1 AND user_id = $2 `, project.ID, userID) } else { _, err = tx.Exec(` UPDATE projects SET priority = $1 WHERE id = $2 AND user_id = $3 `, *project.Priority, project.ID, userID) } if err != nil { log.Printf("Error updating project %d priority: %v", project.ID, err) tx.Rollback() sendErrorWithCORS(w, fmt.Sprintf("Error updating project %d: %v", project.ID, err), http.StatusInternalServerError) return } } // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } // Возвращаем успешный ответ w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": fmt.Sprintf("Updated priorities for %d projects", len(projectsToUpdate)), "updated": len(projectsToUpdate), }) } // confirmPrioritiesHandler сохраняет приоритеты и помечает их как подтверждённые на текущей неделе. // Если приоритеты ещё не подтверждались на этой неделе — также пересчитывает цели. func (a *App) confirmPrioritiesHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } bodyBytes, err := io.ReadAll(r.Body) if err != nil { sendErrorWithCORS(w, "Error reading request body", http.StatusBadRequest) return } defer r.Body.Close() var projectsToUpdate []ProjectPriorityUpdate var directArray []interface{} arrayErr := json.Unmarshal(bodyBytes, &directArray) if arrayErr == nil && len(directArray) > 0 { for _, item := range directArray { if itemMap, ok := item.(map[string]interface{}); ok { var project ProjectPriorityUpdate if idVal, ok := itemMap["id"].(float64); ok { project.ID = int(idVal) } else { continue } if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil { if numVal, ok := priorityVal.(float64); ok { priorityInt := int(numVal) project.Priority = &priorityInt } else { project.Priority = nil } } else { project.Priority = nil } projectsToUpdate = append(projectsToUpdate, project) } } } if len(projectsToUpdate) == 0 { sendErrorWithCORS(w, "No projects to update", http.StatusBadRequest) return } // Проверяем, подтверждены ли уже приоритеты на этой неделе alreadyConfirmed, err := a.isPrioritiesConfirmedForUser(userID) if err != nil { log.Printf("Error checking priorities confirmation for user %d: %v", userID, err) alreadyConfirmed = false } // Сохраняем приоритеты в транзакции tx, err := a.DB.Begin() if err != nil { sendErrorWithCORS(w, "Error beginning transaction", http.StatusInternalServerError) return } defer tx.Rollback() for _, project := range projectsToUpdate { if project.Priority == nil { _, err = tx.Exec(`UPDATE projects SET priority = NULL WHERE id = $1 AND user_id = $2`, project.ID, userID) } else { _, err = tx.Exec(`UPDATE projects SET priority = $1 WHERE id = $2 AND user_id = $3`, *project.Priority, project.ID, userID) } if err != nil { tx.Rollback() sendErrorWithCORS(w, fmt.Sprintf("Error updating project %d", project.ID), http.StatusInternalServerError) return } } if err := tx.Commit(); err != nil { sendErrorWithCORS(w, "Error committing transaction", http.StatusInternalServerError) return } // Помечаем приоритеты как подтверждённые if err := a.setPrioritiesConfirmed(userID); err != nil { log.Printf("Error setting priorities confirmed for user %d: %v", userID, err) } // Пересчитываем цели только если ещё не подтверждали на этой неделе goalsUpdated := false if !alreadyConfirmed { if err := a.setupWeeklyGoalsForUser(userID); err != nil { log.Printf("Error setting up weekly goals for user %d: %v", userID, err) } else { goalsUpdated = true } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Priorities confirmed", "goals_updated": goalsUpdated, }) } type ProjectColorRequest struct { ID int `json:"id"` Color string `json:"color"` } func (a *App) setProjectColorHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req ProjectColorRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding project color request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.ID == 0 { sendErrorWithCORS(w, "id is required", http.StatusBadRequest) return } if req.Color == "" { sendErrorWithCORS(w, "color is required", http.StatusBadRequest) return } // Проверяем, что цвет в правильном формате HEX if !strings.HasPrefix(req.Color, "#") || len(req.Color) != 7 { sendErrorWithCORS(w, "color must be in HEX format (e.g., #FF5733)", http.StatusBadRequest) return } // Обновляем цвет проекта _, err := a.DB.Exec(` UPDATE projects SET color = $1 WHERE id = $2 AND user_id = $3 `, req.Color, req.ID, userID) if err != nil { log.Printf("Error updating project color: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating project color: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project color updated successfully", "id": req.ID, "color": req.Color, }) } type ProjectMoveRequest struct { ID int `json:"id"` NewName string `json:"new_name"` } type ProjectDeleteRequest struct { ID int `json:"id"` } type ProjectCreateRequest struct { Name string `json:"name"` } func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } _ = userID // Will be used in SQL queries var req ProjectMoveRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding move project request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.NewName == "" { sendErrorWithCORS(w, "new_name is required", http.StatusBadRequest) return } // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) return } defer tx.Rollback() // Ищем проект с таким именем var targetProjectID int err = tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND deleted = FALSE `, req.NewName).Scan(&targetProjectID) if err == sql.ErrNoRows { // Проект не найден - просто переименовываем текущий проект _, err = tx.Exec(` UPDATE projects SET name = $1 WHERE id = $2 `, req.NewName, req.ID) if err != nil { log.Printf("Error renaming project: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error renaming project: %v", err), http.StatusInternalServerError) return } // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } // Обновляем MV для групповых саджестов (имя проекта изменилось) if err := a.refreshGroupSuggestionsMV(); err != nil { log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project renamed successfully", "project_id": req.ID, }) return } else if err != nil { log.Printf("Error querying target project: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying target project: %v", err), http.StatusInternalServerError) return } // Проект найден - переносим данные в существующий проект finalProjectID := targetProjectID // Обновляем все nodes с project_id на целевой _, err = tx.Exec(` UPDATE nodes SET project_id = $1 WHERE project_id = $2 `, finalProjectID, req.ID) if err != nil { log.Printf("Error updating nodes: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating nodes: %v", err), http.StatusInternalServerError) return } // Обновляем weekly_goals // Сначала удаляем записи старого проекта, которые конфликтуют с записями целевого проекта // (если у целевого проекта уже есть запись для той же недели) _, err = tx.Exec(` DELETE FROM weekly_goals WHERE project_id = $1 AND EXISTS ( SELECT 1 FROM weekly_goals wg2 WHERE wg2.project_id = $2 AND wg2.goal_year = weekly_goals.goal_year AND wg2.goal_week = weekly_goals.goal_week ) `, req.ID, finalProjectID) if err != nil { log.Printf("Error deleting conflicting weekly_goals: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting conflicting weekly_goals: %v", err), http.StatusInternalServerError) return } // Теперь обновляем оставшиеся записи (те, которые не конфликтуют) // Обновляем project_id и user_id из целевого проекта _, err = tx.Exec(` UPDATE weekly_goals wg SET project_id = $1, user_id = p.user_id FROM projects p WHERE wg.project_id = $2 AND p.id = $1 `, 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 } // Обновляем MV для групповых саджестов (проект переименован или удалён) if err := a.refreshGroupSuggestionsMV(); err != nil { log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project moved successfully", "project_id": finalProjectID, }) } func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req ProjectDeleteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding delete project request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Verify ownership var ownerID int err := a.DB.QueryRow("SELECT user_id FROM projects WHERE id = $1", req.ID).Scan(&ownerID) if err != nil || ownerID != userID { sendErrorWithCORS(w, "Project not found", http.StatusNotFound) return } // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) return } defer tx.Rollback() // Удаляем все записи weekly_goals для этого проекта _, err = tx.Exec(` DELETE FROM weekly_goals WHERE project_id = $1 `, req.ID) if err != nil { log.Printf("Error deleting weekly_goals: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting weekly_goals: %v", err), http.StatusInternalServerError) return } // Помечаем проект как удаленный _, err = tx.Exec(` UPDATE projects SET deleted = TRUE WHERE id = $1 `, req.ID) if err != nil { log.Printf("Error marking project as deleted: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error marking project as deleted: %v", err), http.StatusInternalServerError) return } // Коммитим транзакцию if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } // Обновляем MV для групповых саджестов (проект удалён) if err := a.refreshGroupSuggestionsMV(); err != nil { log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project deleted successfully", }) } func (a *App) createProjectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req ProjectCreateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding create project request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.Name == "" { sendErrorWithCORS(w, "name is required", http.StatusBadRequest) return } // Проверяем, существует ли уже проект с таким именем var existingID int err := a.DB.QueryRow(` SELECT id FROM projects WHERE name = $1 AND user_id = $2 AND deleted = FALSE `, req.Name, userID).Scan(&existingID) if err == nil { // Проект уже существует sendErrorWithCORS(w, "Project with this name already exists", http.StatusConflict) return } else if err != sql.ErrNoRows { log.Printf("Error checking project existence: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking project existence: %v", err), http.StatusInternalServerError) return } // Создаем новый проект randomColor := generateRandomProjectColor() var projectID int err = a.DB.QueryRow(` INSERT INTO projects (name, deleted, user_id, color) VALUES ($1, FALSE, $2, $3) RETURNING id `, req.Name, userID, randomColor).Scan(&projectID) if err != nil { log.Printf("Error creating project: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating project: %v", err), http.StatusInternalServerError) return } // Обновляем MV для групповых саджестов (проекты попадают в саджесты) if err := a.refreshGroupSuggestionsMV(); err != nil { log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Project created successfully", "project_id": projectID, "project_name": req.Name, }) } func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { // Логирование входящего запроса log.Printf("=== Todoist Webhook Request ===") log.Printf("Method: %s", r.Method) log.Printf("URL: %s", r.URL.String()) log.Printf("Path: %s", r.URL.Path) log.Printf("RemoteAddr: %s", r.RemoteAddr) if r.Method == "OPTIONS" { log.Printf("OPTIONS request, returning OK") setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) // Проверка webhook secret (если настроен) todoistWebhookSecret := getEnv("TODOIST_WEBHOOK_SECRET", "") if todoistWebhookSecret != "" { providedSecret := r.Header.Get("X-Todoist-Hmac-SHA256") if providedSecret == "" { providedSecret = r.Header.Get("X-Todoist-Webhook-Secret") } if providedSecret != todoistWebhookSecret { log.Printf("Invalid Todoist webhook secret provided") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Unauthorized", "message": "Invalid webhook secret", }) return } log.Printf("Webhook secret validated successfully") } // Читаем тело запроса bodyBytes, err := io.ReadAll(r.Body) if err != nil { log.Printf("Error reading request body: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Error reading request body", "message": "Failed to read request", }) return } log.Printf("Request body (raw): %s", string(bodyBytes)) log.Printf("Request body length: %d bytes", len(bodyBytes)) // Парсим webhook от Todoist var webhook TodoistWebhook if err := json.Unmarshal(bodyBytes, &webhook); err != nil { log.Printf("Error decoding Todoist webhook: %v", err) log.Printf("Failed to parse body as JSON: %s", string(bodyBytes)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Invalid request body", "message": "Failed to parse JSON", }) return } // Логируем структуру webhook log.Printf("Parsed webhook structure:") log.Printf(" EventName: %s", webhook.EventName) log.Printf(" EventData keys: %v", getMapKeys(webhook.EventData)) // Проверяем, что это событие закрытия задачи if webhook.EventName != "item:completed" { log.Printf("Received Todoist event '%s', ignoring (only processing 'item:completed')", webhook.EventName) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "Event ignored", "event": webhook.EventName, }) return } // Извлекаем user_id из event_data (это Todoist user_id!) var todoistUserID int64 switch v := webhook.EventData["user_id"].(type) { case float64: todoistUserID = int64(v) case string: todoistUserID, _ = strconv.ParseInt(v, 10, 64) default: log.Printf("Todoist webhook: user_id not found or invalid type in event_data") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Missing user_id in event_data", "message": "Cannot identify user", }) return } log.Printf("Todoist webhook: todoist_user_id=%d", todoistUserID) // Находим пользователя Play Life по todoist_user_id var userID int err = a.DB.QueryRow(` SELECT user_id FROM todoist_integrations WHERE todoist_user_id = $1 `, todoistUserID).Scan(&userID) if err == sql.ErrNoRows { // Пользователь не подключил Play Life — игнорируем log.Printf("Todoist webhook: no user found for todoist_user_id=%d (ignoring)", todoistUserID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "User not found (not connected)", }) return } if err != nil { log.Printf("Error finding user by todoist_user_id: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Internal server error", "message": "Database error", }) return } log.Printf("Todoist webhook: todoist_user_id=%d -> user_id=%d", todoistUserID, userID) // Извлекаем content (title) и description из event_data log.Printf("Extracting content and description from event_data...") var title, description string if content, ok := webhook.EventData["content"].(string); ok { title = strings.TrimSpace(content) log.Printf(" Found 'content' (title): '%s' (length: %d)", title, len(title)) } else { log.Printf(" 'content' not found or not a string (type: %T, value: %v)", webhook.EventData["content"], webhook.EventData["content"]) } if desc, ok := webhook.EventData["description"].(string); ok { description = strings.TrimSpace(desc) log.Printf(" Found 'description': '%s' (length: %d)", description, len(description)) } else { log.Printf(" 'description' not found or not a string (type: %T, value: %v)", webhook.EventData["description"], webhook.EventData["description"]) } // Склеиваем title и description // Логика: если есть оба - склеиваем через \n, если только один - используем его var combinedText string if title != "" && description != "" { combinedText = title + "\n" + description log.Printf(" Both title and description present, combining them") } else if title != "" { combinedText = title log.Printf(" Only title present, using title only") } else if description != "" { combinedText = description log.Printf(" Only description present, using description only") } else { combinedText = "" log.Printf(" WARNING: Both title and description are empty!") } log.Printf("Combined text result: '%s' (length: %d)", combinedText, len(combinedText)) // Проверяем, что есть хотя бы title или description if combinedText == "" { log.Printf("ERROR: Todoist webhook: no content or description found in event_data") log.Printf(" title='%s' (empty: %v), description='%s' (empty: %v)", title, title == "", description, description == "") log.Printf("Available keys in event_data: %v", getMapKeys(webhook.EventData)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Missing 'content' or 'description' in event_data", "message": "No content to process", }) return } log.Printf("Processing Todoist task: title='%s' (len=%d), description='%s' (len=%d), combined='%s' (len=%d)", title, len(title), description, len(description), combinedText, len(combinedText)) // Обрабатываем сообщение через существующую логику (без отправки в Telegram) userIDPtr := &userID log.Printf("Calling processMessageWithoutTelegram with combined text, user_id=%d...", userID) response, err := a.processMessageWithoutTelegram(combinedText, userIDPtr) if err != nil { log.Printf("ERROR processing Todoist message: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": err.Error(), "message": "Error processing message", }) return } // Проверяем наличие nodes - если их нет, игнорируем сообщение if len(response.Nodes) == 0 { log.Printf("Todoist webhook: no nodes found in message, ignoring (not saving to database and not sending to Telegram)") log.Printf("=== Todoist Webhook Request Ignored (No Nodes) ===") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "Message ignored (no nodes found)", "ignored": true, }) return } log.Printf("Successfully processed Todoist task, found %d nodes", len(response.Nodes)) if len(response.Nodes) > 0 { log.Printf("Nodes details:") for i, node := range response.Nodes { log.Printf(" Node %d: Project='%s', Score=%f", i+1, node.Project, node.Score) } // Отправляем сообщение в Telegram после успешной обработки log.Printf("Preparing to send message to Telegram...") log.Printf("Combined text to send: '%s'", combinedText) if err := a.sendTelegramMessageToUser(userID, combinedText); err != nil { log.Printf("Error sending Telegram message: %v", err) } else { log.Printf("sendTelegramMessage call completed") } } else { log.Printf("No nodes found, skipping Telegram message") } log.Printf("=== Todoist Webhook Request Completed Successfully ===") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "message": "Task processed successfully", "result": response, }) } func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) // Парсим webhook от Telegram var update TelegramUpdate if err := json.NewDecoder(r.Body).Decode(&update); err != nil { log.Printf("Error decoding Telegram webhook: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, "error": "Invalid request body", }) return } // Определяем сообщение var message *TelegramMessage if update.Message != nil { message = update.Message } else if update.EditedMessage != nil { message = update.EditedMessage } else { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } if message.From == nil { log.Printf("Telegram webhook: message without From field") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } telegramUserID := message.From.ID chatID := message.Chat.ID chatIDStr := strconv.FormatInt(chatID, 10) log.Printf("Telegram webhook: telegram_user_id=%d, chat_id=%d, text=%s", telegramUserID, chatID, message.Text) // Обработка команды /start с токеном if strings.HasPrefix(message.Text, "/start") { parts := strings.Fields(message.Text) if len(parts) > 1 { startToken := parts[1] var userID int err := a.DB.QueryRow(` SELECT user_id FROM telegram_integrations WHERE start_token = $1 `, startToken).Scan(&userID) if err == nil { // Привязываем Telegram к пользователю telegramUserIDStr := strconv.FormatInt(telegramUserID, 10) _, err = a.DB.Exec(` UPDATE telegram_integrations SET telegram_user_id = $1, chat_id = $2, start_token = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = $3 `, telegramUserIDStr, chatIDStr, userID) if err != nil { log.Printf("Error updating telegram integration: %v", err) } else { log.Printf("Telegram connected for user_id=%d", userID) // Приветственное сообщение welcomeMsg := "✅ Telegram успешно подключен к Play Life!\n\nТеперь вы будете получать уведомления и отчеты." if err := a.sendTelegramMessageToChat(chatID, welcomeMsg); err != nil { log.Printf("Error sending welcome message: %v", err) } } } else { log.Printf("Invalid start_token: %s", startToken) a.sendTelegramMessageToChat(chatID, "❌ Неверный токен. Попробуйте получить новую ссылку в приложении.") } } else { // /start без токена a.sendTelegramMessageToChat(chatID, "Привет! Для подключения используйте ссылку из приложения Play Life.") } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } // Обычное сообщение - ищем пользователя по telegram_user_id var userID int err := a.DB.QueryRow(` SELECT user_id FROM telegram_integrations WHERE telegram_user_id = $1 `, telegramUserID).Scan(&userID) if err == sql.ErrNoRows { log.Printf("User not found for telegram_user_id=%d", telegramUserID) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } else if err != nil { log.Printf("Error finding user: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) return } // Обновляем chat_id (на случай переподключения) a.DB.Exec(` UPDATE telegram_integrations SET chat_id = $1, updated_at = CURRENT_TIMESTAMP WHERE user_id = $2 `, chatIDStr, userID) // Обрабатываем сообщение if message.Text == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } entities := message.Entities if entities == nil { entities = []TelegramEntity{} } userIDPtr := &userID response, err := a.processTelegramMessage(message.Text, entities, userIDPtr) if err != nil { log.Printf("Error processing message: %v", err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "ok": true, "result": response, }) } func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Получаем данные текущей недели 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 } // Добавляем pending scores из драфтов с auto_complete=true draftPendingScores, err := a.getDraftPendingScores(userID) if err != nil { log.Printf("Error getting draft pending scores for full statistics: %v", err) } else { for projectID, pendingScore := range draftPendingScores { currentWeekScores[projectID] += pendingScore } } // Получаем ISO год и неделю для текущей даты (в настроенном часовом поясе) timezoneStr := getEnv("TIMEZONE", "UTC") loc, locErr := time.LoadLocation(timezoneStr) if locErr != nil { loc = time.UTC } now := time.Now().In(loc) _, currentWeekInt := now.ISOWeek() currentYearInt := now.Year() 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, -- Normalized score из MV COALESCE(wr.normalized_total_score, 0.0000) AS normalized_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, p.id AS project_id, p.color FROM weekly_report_mv wr FULL OUTER JOIN weekly_goals wg -- Слияние по всем трем ключевым полям ON wr.project_id = wg.project_id AND wr.report_year = wg.goal_year AND wr.report_week = wg.goal_week JOIN projects p -- Присоединяем имя проекта, используя ID из той таблицы, где он не NULL ON p.id = COALESCE(wr.project_id, wg.project_id) WHERE p.deleted = FALSE AND p.user_id = $1 AND COALESCE(wr.report_year, wg.goal_year) IS NOT NULL ORDER BY report_year DESC, report_week DESC, project_name ` rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying full statistics: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying full statistics: %v", err), http.StatusInternalServerError) return } defer rows.Close() statistics := make([]FullStatisticsItem, 0) for rows.Next() { var item FullStatisticsItem var projectID int err := rows.Scan( &item.ProjectName, &item.ReportYear, &item.ReportWeek, &item.TotalScore, &item.NormalizedTotalScore, &item.MinGoalScore, &item.MaxGoalScore, &projectID, &item.Color, ) if err != nil { log.Printf("Error scanning full statistics row: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError) return } // Если это текущая неделя, заменяем данные из MV на данные из nodes if item.ReportYear == currentYearInt && item.ReportWeek == currentWeekInt { if score, exists := currentWeekScores[projectID]; exists { item.TotalScore = score // Нормализованный score для текущей недели — та же логика, что в MV: LEAST(score, max) if item.MaxGoalScore > 0 { item.NormalizedTotalScore = math.Min(score, item.MaxGoalScore) } else { item.NormalizedTotalScore = score } } } // Если normalized_total_score равен total_score, не отправляем его if item.NormalizedTotalScore == item.TotalScore { item.NormalizedTotalScore = 0 } 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, p.color 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 var projectColor string if err := goalsRows.Scan(&projectID, &projectName, &minGoalScore, &maxGoalScore, &projectColor); err == nil { // Добавляем только если проекта еще нет в статистике if !existingProjects[projectID] { totalScore := 0.0 if score, exists := currentWeekScores[projectID]; exists { totalScore = score } // Нормализованный score для текущей недели — та же логика, что в MV: LEAST(score, max) normalizedScore := totalScore if maxGoalScore > 0 { normalizedScore = math.Min(totalScore, maxGoalScore) } _, weekISO := now.ISOWeek() item := FullStatisticsItem{ ProjectName: projectName, ReportYear: now.Year(), ReportWeek: weekISO, TotalScore: totalScore, NormalizedTotalScore: normalizedScore, MinGoalScore: minGoalScore, MaxGoalScore: maxGoalScore, Color: projectColor, } statistics = append(statistics, item) } } } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(statistics) } // getTodayEntriesHandler возвращает entries с nodes за сегодняшний день func (a *App) getTodayEntriesHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Получаем опциональные параметры из query string projectName := r.URL.Query().Get("project") var projectFilter *string if projectName != "" { projectFilter = &projectName } // Получаем дату из query string (формат: YYYY-MM-DD), если не указана - используем сегодня в настроенном часовом поясе dateParam := r.URL.Query().Get("date") var targetDate time.Time if dateParam != "" { parsedDate, err := time.Parse("2006-01-02", dateParam) if err != nil { log.Printf("Error parsing date parameter: %v", err) sendErrorWithCORS(w, "Invalid date format. Use YYYY-MM-DD", http.StatusBadRequest) return } targetDate = parsedDate } else { timezoneStr := getEnv("TIMEZONE", "UTC") loc, locErr := time.LoadLocation(timezoneStr) if locErr != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC for today entries.", timezoneStr, locErr) loc = time.UTC } targetDate = time.Now().In(loc) } // Запрос для получения entries с nodes за указанный день // Если указан проект, показываем все записи, которые содержат хотя бы одну ноду этого проекта, // но возвращаем все ноды этих записей, а не только ноды выбранного проекта query := ` WITH filtered_entries AS ( -- Если проект указан, находим entry_id записей, содержащих хотя бы одну ноду этого проекта SELECT DISTINCT e.id as entry_id FROM entries e JOIN nodes n ON n.entry_id = e.id JOIN projects p ON n.project_id = p.id WHERE DATE(n.created_date) = DATE($3) AND e.user_id = $1 AND n.user_id = $1 AND p.user_id = $1 AND p.deleted = FALSE AND ($2::text IS NULL OR p.name = $2) ), entry_nodes AS ( -- Получаем все ноды для найденных записей (или всех записей, если проект не указан) SELECT e.id as entry_id, e.text, e.created_date, p.name as project_name, n.score, ROW_NUMBER() OVER (PARTITION BY e.id ORDER BY n.id) - 1 as node_index FROM entries e JOIN nodes n ON n.entry_id = e.id JOIN projects p ON n.project_id = p.id WHERE DATE(n.created_date) = DATE($3) AND e.user_id = $1 AND n.user_id = $1 AND p.user_id = $1 AND p.deleted = FALSE AND ($2::text IS NULL OR e.id IN (SELECT entry_id FROM filtered_entries)) ) SELECT entry_id, text, created_date, json_agg( json_build_object( 'project_name', project_name, 'score', score, 'index', node_index ) ORDER BY node_index ) as nodes FROM entry_nodes GROUP BY entry_id, text, created_date ORDER BY created_date DESC ` rows, err := a.DB.Query(query, userID, projectFilter, targetDate) if err != nil { log.Printf("Error querying today entries: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying today entries: %v", err), http.StatusInternalServerError) return } defer rows.Close() entries := make([]TodayEntry, 0) for rows.Next() { var entry TodayEntry var createdDate time.Time var nodesJSON string err := rows.Scan( &entry.ID, &entry.Text, &createdDate, &nodesJSON, ) if err != nil { log.Printf("Error scanning today entry row: %v", err) continue } // Парсим JSON с nodes if err := json.Unmarshal([]byte(nodesJSON), &entry.Nodes); err != nil { log.Printf("Error unmarshaling nodes JSON: %v", err) continue } // Форматируем дату в ISO 8601 entry.CreatedDate = createdDate.Format(time.RFC3339) entries = append(entries, entry) } if err := rows.Err(); err != nil { log.Printf("Error iterating today entries rows: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error iterating rows: %v", err), http.StatusInternalServerError) return } // Если запрошена сегодняшняя дата — добавляем драфты с auto_complete=true в начало списка tzStr := getEnv("TIMEZONE", "UTC") tzLoc, tzErr := time.LoadLocation(tzStr) if tzErr != nil { tzLoc = time.UTC } todayStr := time.Now().In(tzLoc).Format("2006-01-02") targetDateStr := targetDate.Format("2006-01-02") if targetDateStr == todayStr { draftEntries, draftErr := a.getAutoCompleteDraftEntries(userID) if draftErr != nil { log.Printf("Error getting auto complete draft entries: %v", draftErr) } else { // Применяем фильтр по проекту если указан if projectFilter != nil { filtered := make([]TodayEntry, 0, len(draftEntries)) for _, de := range draftEntries { for _, node := range de.Nodes { if node.ProjectName == *projectFilter { filtered = append(filtered, de) break } } } draftEntries = filtered } entries = append(draftEntries, entries...) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(entries) } // deleteEntryHandler удаляет entry и каскадно удаляет связанные nodes func (a *App) deleteEntryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) entryIDStr := vars["id"] entryID, err := strconv.Atoi(entryIDStr) if err != nil { sendErrorWithCORS(w, "Invalid entry ID", http.StatusBadRequest) return } // Проверяем, что entry принадлежит пользователю var entryUserID int err = a.DB.QueryRow("SELECT user_id FROM entries WHERE id = $1", entryID).Scan(&entryUserID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Entry not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking entry ownership: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Проверяем права доступа if entryUserID != userID { sendErrorWithCORS(w, "Forbidden", http.StatusForbidden) return } // Удаляем entry (nodes удалятся каскадно из-за ON DELETE CASCADE) result, err := a.DB.Exec("DELETE FROM entries WHERE id = $1 AND user_id = $2", entryID, userID) if err != nil { log.Printf("Error deleting entry: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } rowsAffected, err := result.RowsAffected() if err != nil { log.Printf("Error getting rows affected: %v", err) sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if rowsAffected == 0 { sendErrorWithCORS(w, "Entry not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "message": "Entry deleted successfully", }) } // updateEntryHandler обновляет текст и ноды записи, сохраняя оригинальную дату func (a *App) updateEntryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) entryIDStr := vars["id"] entryID, err := strconv.Atoi(entryIDStr) if err != nil { sendErrorWithCORS(w, "Invalid entry ID", http.StatusBadRequest) return } var req struct { Text string `json:"text"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.Text == "" { sendErrorWithCORS(w, "Missing 'text' field", http.StatusBadRequest) return } // Получаем оригинальную дату и проверяем права var originalDate string var entryUserID int err = a.DB.QueryRow("SELECT user_id, created_date FROM entries WHERE id = $1", entryID).Scan(&entryUserID, &originalDate) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Entry not found", http.StatusNotFound) return } if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } if entryUserID != userID { sendErrorWithCORS(w, "Forbidden", http.StatusForbidden) return } // Парсим ноды из нового текста regex := regexp.MustCompile(`\*\*(.+?)([+-])([\d.]+)\*\*`) nodes := make([]ProcessedNode, 0) nodeCounter := 0 processedText := regex.ReplaceAllStringFunc(req.Text, 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 { return fullMatch } if sign == "-" { score = -score } 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") // Обновляем в транзакции: удаляем старые ноды, вставляем новые, обновляем текст tx, err := a.DB.Begin() if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } defer tx.Rollback() // Удаляем старые ноды if _, err = tx.Exec("DELETE FROM nodes WHERE entry_id = $1", entryID); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Обновляем текст записи if _, err = tx.Exec("UPDATE entries SET text = $1 WHERE id = $2 AND user_id = $3", processedText, entryID, userID); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } // Вставляем новые ноды for _, node := range nodes { var projectID int err = tx.QueryRow(` SELECT id FROM projects WHERE name = $1 AND user_id = $2 AND deleted = FALSE `, node.Project, userID).Scan(&projectID) if err == sql.ErrNoRows { randomColor := generateRandomProjectColor() err = tx.QueryRow(` INSERT INTO projects (name, deleted, user_id, color) VALUES ($1, FALSE, $2, $3) RETURNING id `, node.Project, userID, randomColor).Scan(&projectID) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } } else if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) return } _, err = tx.Exec(` INSERT INTO nodes (project_id, entry_id, score, user_id, created_date) VALUES ($1, $2, $3, $4, $5) `, projectID, entryID, node.Score, userID, originalDate) if err != nil { sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError) 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": "Entry updated successfully", }) } // getTelegramIntegrationHandler возвращает текущую telegram интеграцию с deep link func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } integration, err := a.getTelegramIntegrationForUser(userID) if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to get telegram integration: %v", err), http.StatusInternalServerError) return } // Генерируем start_token если его нет if integration.StartToken == nil || *integration.StartToken == "" { token, err := generateWebhookToken() if err == nil { _, _ = a.DB.Exec(` UPDATE telegram_integrations SET start_token = $1, updated_at = CURRENT_TIMESTAMP WHERE user_id = $2 `, token, userID) integration.StartToken = &token } } // Формируем deep link var deepLink string if a.telegramBotUsername != "" && integration.StartToken != nil { deepLink = fmt.Sprintf("https://t.me/%s?start=%s", a.telegramBotUsername, *integration.StartToken) } isConnected := integration.ChatID != nil && integration.TelegramUserID != nil w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "id": integration.ID, "telegram_user_id": integration.TelegramUserID, "is_connected": isConnected, "deep_link": deepLink, }) } // updateTelegramIntegrationHandler больше не используется (bot_token теперь в .env) // Оставлен для совместимости, возвращает ошибку func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) sendErrorWithCORS(w, "Bot token is now configured via TELEGRAM_BOT_TOKEN environment variable", http.StatusBadRequest) } // OAuthStateClaims структура для OAuth state JWT type OAuthStateClaims struct { UserID int `json:"user_id"` Type string `json:"type"` jwt.RegisteredClaims } // generateOAuthState генерирует JWT state для OAuth func generateOAuthState(userID int, jwtSecret []byte) (string, error) { claims := OAuthStateClaims{ UserID: userID, Type: "todoist_oauth", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // 1 день IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(jwtSecret) } // validateOAuthState проверяет и извлекает user_id из JWT state func validateOAuthState(stateString string, jwtSecret []byte) (int, error) { token, err := jwt.ParseWithClaims(stateString, &OAuthStateClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return jwtSecret, nil }) if err != nil { return 0, err } claims, ok := token.Claims.(*OAuthStateClaims) if !ok || !token.Valid { return 0, fmt.Errorf("invalid token") } if claims.Type != "todoist_oauth" { return 0, fmt.Errorf("wrong token type") } return claims.UserID, nil } // exchangeCodeForToken обменивает OAuth code на access_token func exchangeCodeForToken(code, redirectURI, clientID, clientSecret string) (string, error) { data := url.Values{} data.Set("client_id", clientID) data.Set("client_secret", clientSecret) data.Set("code", code) data.Set("redirect_uri", redirectURI) resp, err := http.PostForm("https://todoist.com/oauth/access_token", data) if err != nil { return "", fmt.Errorf("failed to exchange code: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("token exchange failed: %s", string(body)) } var result struct { AccessToken string `json:"access_token"` Error string `json:"error"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to decode response: %w", err) } if result.Error != "" { return "", fmt.Errorf("token exchange error: %s", result.Error) } return result.AccessToken, nil } // getTodoistUserInfo получает информацию о пользователе через Sync API func getTodoistUserInfo(accessToken string) (struct { ID int64 Email string }, error) { var userInfo struct { ID int64 Email string } // Формируем правильный запрос к Sync API data := url.Values{} data.Set("sync_token", "*") data.Set("resource_types", `["user"]`) req, err := http.NewRequest("POST", "https://api.todoist.com/sync/v9/sync", strings.NewReader(data.Encode())) if err != nil { log.Printf("Todoist API: failed to create request: %v", err) return userInfo, err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("User-Agent", "PlayLife") log.Printf("Todoist API: requesting user info from sync/v9/sync") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { log.Printf("Todoist API: request failed: %v", err) return userInfo, fmt.Errorf("failed to get user info: %w", err) } defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) log.Printf("Todoist API: response status=%d, body=%s", resp.StatusCode, string(bodyBytes)) if resp.StatusCode != http.StatusOK { return userInfo, fmt.Errorf("get user info failed (status %d): %s", resp.StatusCode, string(bodyBytes)) } // Парсим ответ - в Sync API user может быть объектом или массивом var result map[string]interface{} if err := json.Unmarshal(bodyBytes, &result); err != nil { log.Printf("Todoist API: failed to parse JSON: %v, body: %s", err, string(bodyBytes)) return userInfo, fmt.Errorf("failed to decode user info: %w", err) } log.Printf("Todoist API: parsed response keys: %v", getMapKeys(result)) // Функция для извлечения ID из разных типов extractID := func(idValue interface{}) int64 { switch v := idValue.(type) { case float64: return int64(v) case int64: return v case int: return int64(v) case string: if id, err := strconv.ParseInt(v, 10, 64); err == nil { return id } } return 0 } // Проверяем разные варианты структуры ответа if userObj, ok := result["user"].(map[string]interface{}); ok { // Один объект user userInfo.ID = extractID(userObj["id"]) if email, ok := userObj["email"].(string); ok { userInfo.Email = email } } else if usersArr, ok := result["user"].([]interface{}); ok && len(usersArr) > 0 { // Массив users, берем первый if userObj, ok := usersArr[0].(map[string]interface{}); ok { userInfo.ID = extractID(userObj["id"]) if email, ok := userObj["email"].(string); ok { userInfo.Email = email } } } else { log.Printf("Todoist API: user not found in response, available keys: %v", getMapKeys(result)) return userInfo, fmt.Errorf("user not found in response") } if userInfo.ID == 0 || userInfo.Email == "" { log.Printf("Todoist API: incomplete user info: ID=%d, Email=%s", userInfo.ID, userInfo.Email) return userInfo, fmt.Errorf("incomplete user info: ID=%d, Email=%s", userInfo.ID, userInfo.Email) } log.Printf("Todoist API: successfully got user info: ID=%d, Email=%s", userInfo.ID, userInfo.Email) return userInfo, nil } // todoistOAuthConnectHandler инициирует OAuth flow func (a *App) todoistOAuthConnectHandler(w http.ResponseWriter, r *http.Request) { setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } clientID := getEnv("TODOIST_CLIENT_ID", "") clientSecret := getEnv("TODOIST_CLIENT_SECRET", "") baseURL := getEnv("WEBHOOK_BASE_URL", "") if clientID == "" || clientSecret == "" { sendErrorWithCORS(w, "TODOIST_CLIENT_ID and TODOIST_CLIENT_SECRET must be configured", http.StatusInternalServerError) return } if baseURL == "" { sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError) return } redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback" state, err := generateOAuthState(userID, a.jwtSecret) if err != nil { log.Printf("Todoist OAuth: failed to generate state: %v", err) sendErrorWithCORS(w, "Failed to generate OAuth state", http.StatusInternalServerError) return } authURL := fmt.Sprintf( "https://todoist.com/oauth/authorize?client_id=%s&scope=data:read_write&state=%s&redirect_uri=%s", url.QueryEscape(clientID), url.QueryEscape(state), url.QueryEscape(redirectURI), ) log.Printf("Todoist OAuth: returning auth URL for user_id=%d", userID) // Возвращаем JSON с URL для редиректа (frontend сделает редирект) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "auth_url": authURL, }) } // todoistOAuthCallbackHandler обрабатывает OAuth callback func (a *App) todoistOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { frontendURL := getEnv("WEBHOOK_BASE_URL", "") redirectSuccess := frontendURL + "/?integration=todoist&status=connected" redirectError := frontendURL + "/?integration=todoist&status=error" clientID := getEnv("TODOIST_CLIENT_ID", "") clientSecret := getEnv("TODOIST_CLIENT_SECRET", "") baseURL := getEnv("WEBHOOK_BASE_URL", "") if clientID == "" || clientSecret == "" || baseURL == "" { log.Printf("Todoist OAuth: missing configuration") http.Redirect(w, r, redirectError+"&message=config_error", http.StatusTemporaryRedirect) return } redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback" // Проверяем state state := r.URL.Query().Get("state") userID, err := validateOAuthState(state, a.jwtSecret) if err != nil { log.Printf("Todoist OAuth: invalid state: %v", err) http.Redirect(w, r, redirectError+"&message=invalid_state", http.StatusTemporaryRedirect) return } // Получаем code code := r.URL.Query().Get("code") if code == "" { log.Printf("Todoist OAuth: no code in callback") http.Redirect(w, r, redirectError+"&message=no_code", http.StatusTemporaryRedirect) return } // Обмениваем code на access_token accessToken, err := exchangeCodeForToken(code, redirectURI, clientID, clientSecret) if err != nil { log.Printf("Todoist OAuth: token exchange failed: %v", err) http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect) return } // Получаем информацию о пользователе todoistUser, err := getTodoistUserInfo(accessToken) if err != nil { log.Printf("Todoist OAuth: get user info failed: %v", err) http.Redirect(w, r, redirectError+"&message=user_info_failed", http.StatusTemporaryRedirect) return } log.Printf("Todoist OAuth: user_id=%d connected todoist_user_id=%d email=%s", userID, todoistUser.ID, todoistUser.Email) // Сохраняем в БД _, err = a.DB.Exec(` INSERT INTO todoist_integrations (user_id, todoist_user_id, todoist_email, access_token) VALUES ($1, $2, $3, $4) ON CONFLICT (user_id) DO UPDATE SET todoist_user_id = $2, todoist_email = $3, access_token = $4, updated_at = CURRENT_TIMESTAMP `, userID, todoistUser.ID, todoistUser.Email, accessToken) if err != nil { log.Printf("Todoist OAuth: DB error: %v", err) http.Redirect(w, r, redirectError+"&message=db_error", http.StatusTemporaryRedirect) return } // Редирект на страницу интеграций http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect) } // getTodoistStatusHandler возвращает статус подключения Todoist func (a *App) getTodoistStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var todoistEmail sql.NullString err := a.DB.QueryRow(` SELECT todoist_email FROM todoist_integrations WHERE user_id = $1 AND access_token IS NOT NULL `, userID).Scan(&todoistEmail) if err == sql.ErrNoRows || !todoistEmail.Valid { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "connected": false, }) return } if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to get status: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "connected": true, "todoist_email": todoistEmail.String, }) } // ============================================ // Tasks handlers // ============================================ // fetchTasksForUser возвращает список задач пользователя из БД func (a *App) fetchTasksForUser(userID int) ([]Task, error) { query := ` SELECT t.id, t.name, t.completed, t.last_completed_at, t.next_show_at, t.repetition_period::text, t.repetition_date, t.progression_base, t.wishlist_id, t.config_id, t.purchase_config_id, t.reward_policy, t.group_name, COALESCE(( SELECT COUNT(*) FROM tasks st WHERE st.parent_task_id = t.id AND st.deleted = FALSE ), 0) as subtasks_count, COALESCE( (SELECT array_agg(DISTINCT p.name) FILTER (WHERE p.name IS NOT NULL) FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = t.id), ARRAY[]::text[] ) as project_names, COALESCE( (SELECT array_agg(DISTINCT p.name) FILTER (WHERE p.name IS NOT NULL) FROM tasks st JOIN reward_configs rc ON rc.task_id = st.id JOIN projects p ON rc.project_id = p.id WHERE st.parent_task_id = t.id AND st.deleted = FALSE), ARRAY[]::text[] ) as subtask_project_names, COALESCE(td.auto_complete, FALSE) as auto_complete, td.progression_value as draft_progression_value, CASE WHEN td.id IS NOT NULL THEN (SELECT COUNT(*) FROM task_draft_subtasks tds WHERE tds.task_draft_id = td.id) ELSE NULL END as draft_subtasks_count FROM tasks t LEFT JOIN task_drafts td ON td.task_id = t.id AND td.user_id = $1 WHERE t.user_id = $1 AND t.parent_task_id IS NULL AND t.deleted = FALSE ORDER BY -- Сначала разделяем на невыполненные (0) и выполненные (1) CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN 0 ELSE 1 END, -- Для невыполненных: сортируем по completed DESC (больше завершений выше), затем по id ASC (раньше добавленные выше) CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN -t.completed ELSE 0 END, CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN t.id ELSE 0 END, -- Для выполненных: сортируем по next_show_at ASC (ранние в начале), NULL значения в конце через COALESCE CASE WHEN t.last_completed_at IS NOT NULL AND t.last_completed_at::date >= CURRENT_DATE THEN COALESCE(t.next_show_at, '9999-12-31'::timestamp with time zone) ELSE '1970-01-01'::timestamp with time zone END ` rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying tasks: %v", err) return nil, err } defer rows.Close() tasks := make([]Task, 0) for rows.Next() { var task Task var lastCompletedAt sql.NullString var nextShowAt sql.NullString var repetitionPeriod sql.NullString var repetitionDate sql.NullString var progressionBase sql.NullFloat64 var wishlistID sql.NullInt64 var configID sql.NullInt64 var purchaseConfigID sql.NullInt64 var rewardPolicy sql.NullString var groupName sql.NullString var projectNames pq.StringArray var subtaskProjectNames pq.StringArray var autoComplete bool var draftProgressionValue sql.NullFloat64 var draftSubtasksCount sql.NullInt64 err := rows.Scan( &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &repetitionPeriod, &repetitionDate, &progressionBase, &wishlistID, &configID, &purchaseConfigID, &rewardPolicy, &groupName, &task.SubtasksCount, &projectNames, &subtaskProjectNames, &autoComplete, &draftProgressionValue, &draftSubtasksCount, ) if err != nil { log.Printf("Error scanning task: %v", err) continue } if lastCompletedAt.Valid { task.LastCompletedAt = &lastCompletedAt.String } if nextShowAt.Valid { task.NextShowAt = &nextShowAt.String } if repetitionPeriod.Valid { task.RepetitionPeriod = &repetitionPeriod.String } if repetitionDate.Valid { task.RepetitionDate = &repetitionDate.String } if progressionBase.Valid { task.HasProgression = true task.ProgressionBase = &progressionBase.Float64 } else { task.HasProgression = false } if wishlistID.Valid { wishlistIDInt := int(wishlistID.Int64) task.WishlistID = &wishlistIDInt } if configID.Valid { configIDInt := int(configID.Int64) task.ConfigID = &configIDInt } if purchaseConfigID.Valid { purchaseConfigIDInt := int(purchaseConfigID.Int64) task.PurchaseConfigID = &purchaseConfigIDInt } if rewardPolicy.Valid { task.RewardPolicy = &rewardPolicy.String } if groupName.Valid && groupName.String != "" { groupNameVal := groupName.String task.GroupName = &groupNameVal } task.AutoComplete = autoComplete if draftProgressionValue.Valid { task.DraftProgressionValue = &draftProgressionValue.Float64 } if draftSubtasksCount.Valid { count := int(draftSubtasksCount.Int64) task.DraftSubtasksCount = &count } // Объединяем проекты из основной задачи и подзадач allProjects := make(map[string]bool) for _, pn := range projectNames { if pn != "" { allProjects[pn] = true } } for _, pn := range subtaskProjectNames { if pn != "" { allProjects[pn] = true } } task.ProjectNames = make([]string, 0, len(allProjects)) for pn := range allProjects { task.ProjectNames = append(task.ProjectNames, pn) } tasks = append(tasks, task) } return tasks, nil } // getTasksHandler возвращает список задач пользователя func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } tasks, err := a.fetchTasksForUser(userID) if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Error querying tasks: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tasks) } // getTaskDetailHandler возвращает детальную информацию о задаче func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } // Получаем основную задачу var task Task var rewardMessage sql.NullString var progressionBase sql.NullFloat64 var lastCompletedAt sql.NullString var nextShowAt sql.NullString var repetitionPeriod sql.NullString var repetitionDate sql.NullString var wishlistID sql.NullInt64 var configID sql.NullInt64 var purchaseConfigID sql.NullInt64 var rewardPolicy sql.NullString var groupName sql.NullString // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL var repetitionPeriodStr string var repetitionDateStr string err = a.DB.QueryRow(` SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base, CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period, COALESCE(repetition_date, '') as repetition_date, wishlist_id, config_id, purchase_config_id, reward_policy, group_name FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE `, taskID, userID).Scan( &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &purchaseConfigID, &rewardPolicy, &groupName, ) log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr) // Преобразуем в sql.NullString для совместимости if repetitionPeriodStr != "" { repetitionPeriod = sql.NullString{String: repetitionPeriodStr, Valid: true} } else { repetitionPeriod = sql.NullString{Valid: false} } if repetitionDateStr != "" { repetitionDate = sql.NullString{String: repetitionDateStr, Valid: true} } else { repetitionDate = sql.NullString{Valid: false} } if err == sql.ErrNoRows { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error querying task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying task: %v", err), http.StatusInternalServerError) return } if rewardMessage.Valid { task.RewardMessage = &rewardMessage.String } if progressionBase.Valid { task.ProgressionBase = &progressionBase.Float64 } if lastCompletedAt.Valid { task.LastCompletedAt = &lastCompletedAt.String } if nextShowAt.Valid { task.NextShowAt = &nextShowAt.String } if repetitionPeriod.Valid && repetitionPeriod.String != "" { task.RepetitionPeriod = &repetitionPeriod.String log.Printf("Task %d has repetition_period: %s", task.ID, repetitionPeriod.String) } else { log.Printf("Task %d has no repetition_period (Valid: %v, String: '%s')", task.ID, repetitionPeriod.Valid, repetitionPeriod.String) } if repetitionDate.Valid && repetitionDate.String != "" { task.RepetitionDate = &repetitionDate.String log.Printf("Task %d has repetition_date: %s", task.ID, repetitionDate.String) } if wishlistID.Valid { wishlistIDInt := int(wishlistID.Int64) task.WishlistID = &wishlistIDInt } if configID.Valid { configIDInt := int(configID.Int64) task.ConfigID = &configIDInt } if purchaseConfigID.Valid { purchaseConfigIDInt := int(purchaseConfigID.Int64) task.PurchaseConfigID = &purchaseConfigIDInt } if rewardPolicy.Valid { task.RewardPolicy = &rewardPolicy.String } if groupName.Valid && groupName.String != "" { groupNameVal := groupName.String task.GroupName = &groupNameVal } // Получаем награды основной задачи rewards := make([]Reward, 0) rewardRows, err := a.DB.Query(` SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = $1 ORDER BY rc.position `, taskID) if err != nil { log.Printf("Error querying rewards: %v", err) } else { defer rewardRows.Close() for rewardRows.Next() { var reward Reward err := rewardRows.Scan(&reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning reward: %v", err) continue } rewards = append(rewards, reward) } } // Получаем подзадачи subtasks := make([]Subtask, 0) subtaskRows, err := a.DB.Query(` SELECT id, name, completed, last_completed_at, reward_message, progression_base, position FROM tasks WHERE parent_task_id = $1 AND deleted = FALSE ORDER BY COALESCE(position, id) `, taskID) if err != nil { log.Printf("Error querying subtasks: %v", err) } else { defer subtaskRows.Close() subtaskMap := make(map[int]*Subtask) subtaskIDs := make([]int, 0) for subtaskRows.Next() { var subtaskTask Task var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 var subtaskLastCompletedAt sql.NullString var subtaskPosition sql.NullInt64 err := subtaskRows.Scan( &subtaskTask.ID, &subtaskTask.Name, &subtaskTask.Completed, &subtaskLastCompletedAt, &subtaskRewardMessage, &subtaskProgressionBase, &subtaskPosition, ) if err != nil { log.Printf("Error scanning subtask: %v", err) continue } if subtaskRewardMessage.Valid { subtaskTask.RewardMessage = &subtaskRewardMessage.String } if subtaskProgressionBase.Valid { subtaskTask.ProgressionBase = &subtaskProgressionBase.Float64 } if subtaskLastCompletedAt.Valid { subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String } if subtaskPosition.Valid { pos := int(subtaskPosition.Int64) subtaskTask.Position = &pos } subtaskIDs = append(subtaskIDs, subtaskTask.ID) subtask := Subtask{ Task: subtaskTask, Rewards: make([]Reward, 0), } subtaskMap[subtaskTask.ID] = &subtask } // Загружаем все награды всех подзадач одним запросом if len(subtaskIDs) > 0 { // Используем параметризованный запрос с ANY(ARRAY[...]) query := ` SELECT rc.task_id, rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = ANY($1::int[]) ORDER BY rc.task_id, rc.position ` subtaskRewardRows, err := a.DB.Query(query, pq.Array(subtaskIDs)) if err != nil { log.Printf("Error querying subtask rewards: %v", err) } else { defer subtaskRewardRows.Close() for subtaskRewardRows.Next() { var taskID int var reward Reward err := subtaskRewardRows.Scan(&taskID, &reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning subtask reward: %v", err) continue } if subtask, exists := subtaskMap[taskID]; exists { subtask.Rewards = append(subtask.Rewards, reward) } } } } // Преобразуем map в slice, сохраняя порядок по ID for _, id := range subtaskIDs { if subtask, exists := subtaskMap[id]; exists { subtasks = append(subtasks, *subtask) } } } // Инициализируем auto_complete значением по умолчанию task.AutoComplete = false // Загружаем данные из драфта, если он существует var draftProgressionValue sql.NullFloat64 var draftAutoComplete sql.NullBool var draftProgressionValuePtr *float64 var draftSubtasks []DraftSubtask err = a.DB.QueryRow(` SELECT progression_value, auto_complete FROM task_drafts WHERE task_id = $1 AND user_id = $2 `, taskID, userID).Scan(&draftProgressionValue, &draftAutoComplete) if err == nil { // Драфт существует, загружаем данные if draftProgressionValue.Valid { draftProgressionValuePtr = &draftProgressionValue.Float64 } // Устанавливаем auto_complete из драфта (если Valid, иначе остается false) if draftAutoComplete.Valid { task.AutoComplete = draftAutoComplete.Bool log.Printf("Task %d: auto_complete set to %v from draft", taskID, task.AutoComplete) } else { log.Printf("Task %d: draft exists but auto_complete is NULL, keeping default false", taskID) } // Загружаем подзадачи из драфта draftSubtaskRows, err := a.DB.Query(` SELECT subtask_id FROM task_draft_subtasks WHERE task_draft_id = (SELECT id FROM task_drafts WHERE task_id = $1 AND user_id = $2) `, taskID, userID) if err == nil { defer draftSubtaskRows.Close() draftSubtasks = make([]DraftSubtask, 0) validSubtaskIDs := make(map[int]bool) // Создаем map валидных подзадач для фильтрации for _, subtask := range subtasks { validSubtaskIDs[subtask.Task.ID] = true } for draftSubtaskRows.Next() { var subtaskID int if err := draftSubtaskRows.Scan(&subtaskID); err == nil { // Игнорируем подзадачи, которых больше нет в основной задаче if validSubtaskIDs[subtaskID] { draftSubtasks = append(draftSubtasks, DraftSubtask{ SubtaskID: subtaskID, }) } } } } else if err != sql.ErrNoRows { log.Printf("Error loading draft subtasks for task %d: %v", taskID, err) } } else if err != sql.ErrNoRows { log.Printf("Error loading draft for task %d: %v", taskID, err) } else { log.Printf("Task %d: no draft found, auto_complete remains false", taskID) } // Если драфта нет (err == sql.ErrNoRows), auto_complete остается false log.Printf("Task %d: final auto_complete value = %v", taskID, task.AutoComplete) response := TaskDetail{ Task: task, Rewards: rewards, Subtasks: subtasks, } // Устанавливаем DraftProgressionValue если он был загружен if draftProgressionValuePtr != nil { response.DraftProgressionValue = draftProgressionValuePtr } // Устанавливаем DraftSubtasks если они были загружены if len(draftSubtasks) > 0 { response.DraftSubtasks = draftSubtasks } // Если задача связана с wishlist, загружаем базовую информацию о wishlist if wishlistID.Valid { var wishlistName string err := a.DB.QueryRow(` SELECT name 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, Unlocked: unlocked, } } else if err != sql.ErrNoRows { log.Printf("Error loading wishlist info for task %d: %v", taskID, err) } } // Если задача - тест (есть config_id), загружаем данные конфигурации if configID.Valid { var wordsCount int var maxCards sql.NullInt64 err := a.DB.QueryRow(` SELECT words_count, max_cards 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 FROM config_dictionaries WHERE config_id = $1 `, configID.Int64) if err == nil { defer dictRows.Close() dictionaryIDs := make([]int, 0) for dictRows.Next() { var dictID int if err := dictRows.Scan(&dictID); err == nil { dictionaryIDs = append(dictionaryIDs, dictID) } } if len(dictionaryIDs) > 0 { response.DictionaryIDs = dictionaryIDs } } } else { log.Printf("Error loading config for task %d: %v", taskID, err) } } // Если задача - закупка (есть purchase_config_id), загружаем данные конфигурации if purchaseConfigID.Valid { boardRows, err := a.DB.Query(` SELECT pcb.board_id, sb.name, pcb.group_name FROM purchase_config_boards pcb JOIN shopping_boards sb ON sb.id = pcb.board_id WHERE pcb.purchase_config_id = $1 `, purchaseConfigID.Int64) if err == nil { defer boardRows.Close() purchaseBoards := make([]PurchaseBoardInfo, 0) for boardRows.Next() { var info PurchaseBoardInfo var groupName sql.NullString if err := boardRows.Scan(&info.BoardID, &info.BoardName, &groupName); err == nil { if groupName.Valid { info.GroupName = &groupName.String } purchaseBoards = append(purchaseBoards, info) } } if len(purchaseBoards) > 0 { response.PurchaseBoards = purchaseBoards } } else { log.Printf("Error loading purchase config for task %d: %v", taskID, err) } } log.Printf("Task %d: Sending response with auto_complete = %v (task.AutoComplete = %v)", taskID, response.Task.AutoComplete, task.AutoComplete) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // findProjectByName находит проект по имени (регистронезависимо) или возвращает ошибку func (a *App) findProjectByName(projectName string, userID int) (int, error) { var projectID int err := a.DB.QueryRow(` SELECT id FROM projects WHERE LOWER(name) = LOWER($1) AND user_id = $2 AND deleted = FALSE `, projectName, userID).Scan(&projectID) if err == sql.ErrNoRows { return 0, fmt.Errorf("project not found: %s", projectName) } if err != nil { return 0, fmt.Errorf("error finding project: %w", err) } return projectID, nil } // findProjectByNameTx находит проект по имени в транзакции func (a *App) findProjectByNameTx(tx *sql.Tx, projectName string, userID int) (int, error) { var projectID int err := tx.QueryRow(` SELECT id FROM projects WHERE LOWER(name) = LOWER($1) AND user_id = $2 AND deleted = FALSE `, projectName, userID).Scan(&projectID) if err == sql.ErrNoRows { return 0, fmt.Errorf("project not found: %s", projectName) } if err != nil { return 0, fmt.Errorf("error finding project: %w", err) } return projectID, nil } // createTaskHandler создает новую задачу func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req TaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding task request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Валидация if len(strings.TrimSpace(req.Name)) < 1 { sendErrorWithCORS(w, "Task name is required and must be at least 1 character", http.StatusBadRequest) return } // Проверяем, что все rewards имеют project_name for _, reward := range req.Rewards { if strings.TrimSpace(reward.ProjectName) == "" { sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) return } } // Валидация wishlist_id: если указан, проверяем что желание существует и пользователь имеет доступ var wishlistName string if req.WishlistID != nil { var wishlistOwnerID int var authorID sql.NullInt64 var boardID sql.NullInt64 err := a.DB.QueryRow(` SELECT user_id, name, author_id, board_id FROM wishlist_items WHERE id = $1 AND deleted = FALSE `, *req.WishlistID).Scan(&wishlistOwnerID, &wishlistName, &authorID, &boardID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Wishlist item not found", http.StatusBadRequest) return } if err != nil { log.Printf("Error checking wishlist item: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist item: %v", err), http.StatusInternalServerError) return } hasAccess := wishlistOwnerID == userID // Проверяем, является ли пользователь автором желания if !hasAccess && authorID.Valid && authorID.Int64 == int64(userID) { hasAccess = true } // Проверяем доступ к доске, если желание принадлежит доске if !hasAccess && boardID.Valid { var boardOwnerID int err := a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID.Int64).Scan(&boardOwnerID) if err == nil && boardOwnerID == userID { hasAccess = true } else if err == nil { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, boardID.Int64, userID).Scan(&isMember) if isMember { hasAccess = true } } } if !hasAccess { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } // Проверяем, что нет другой активной (не удаленной и не выполненной) задачи с таким wishlist_id для этого пользователя // Если задача была выполнена (completed > 0) или удалена, можно создать новую var existingTaskID int var existingTaskCompleted int err = a.DB.QueryRow(` SELECT id, completed FROM tasks WHERE wishlist_id = $1 AND user_id = $2 AND deleted = FALSE `, *req.WishlistID, userID).Scan(&existingTaskID, &existingTaskCompleted) if err != sql.ErrNoRows { if err != nil { log.Printf("Error checking existing task for wishlist: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking existing task: %v", err), http.StatusInternalServerError) return } // Если задача была выполнена (completed > 0), можно создать новую if existingTaskCompleted > 0 { log.Printf("Existing task %d for wishlist %d was completed (%d times), marking as deleted and allowing new task creation", existingTaskID, *req.WishlistID, existingTaskCompleted) // Помечаем старую выполненную задачу как удаленную, чтобы избежать конфликта с уникальным индексом _, err = a.DB.Exec(` UPDATE tasks SET deleted = TRUE WHERE id = $1 `, existingTaskID) if err != nil { log.Printf("Error marking existing completed task as deleted: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error marking existing task as deleted: %v", err), http.StatusInternalServerError) return } } else { 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 := "Выполнить желание: $name" req.RewardMessage = &rewardMsg } // Задачи, привязанные к желанию, не могут быть периодическими if (req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "") || (req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "") { // Проверяем, что это не бесконечная задача (оба поля = 0) isPeriodZero := req.RepetitionPeriod != nil && (strings.TrimSpace(*req.RepetitionPeriod) == "0 day" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionPeriod), "0 ")) isDateZero := req.RepetitionDate != nil && (strings.TrimSpace(*req.RepetitionDate) == "0 week" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionDate), "0 ")) if !isPeriodZero || !isDateZero { sendErrorWithCORS(w, "Tasks linked to wishlist items cannot be periodic", http.StatusBadRequest) return } } // Задачи, привязанные к желанию, не могут иметь прогрессию if req.ProgressionBase != nil { sendErrorWithCORS(w, "Tasks linked to wishlist items cannot have progression", http.StatusBadRequest) return } } // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) return } defer tx.Rollback() // Создаем основную задачу var taskID int var rewardMessage sql.NullString var progressionBase sql.NullFloat64 var repetitionPeriod sql.NullString var repetitionDate sql.NullString if req.RewardMessage != nil { rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true} } if req.ProgressionBase != nil { progressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } if req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "" { repetitionPeriod = sql.NullString{String: strings.TrimSpace(*req.RepetitionPeriod), Valid: true} log.Printf("Creating task with repetition_period: %s", repetitionPeriod.String) } else { log.Printf("Creating task without repetition_period (req.RepetitionPeriod: %v)", req.RepetitionPeriod) } if req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "" { repetitionDate = sql.NullString{String: strings.TrimSpace(*req.RepetitionDate), Valid: true} log.Printf("Creating task with repetition_date: %s", repetitionDate.String) } // Используем CAST для преобразования строки в INTERVAL var repetitionPeriodValue interface{} if repetitionPeriod.Valid { repetitionPeriodValue = repetitionPeriod.String } else { repetitionPeriodValue = nil } // Подготовка wishlist_id для INSERT var wishlistIDValue interface{} if req.WishlistID != nil { wishlistIDValue = *req.WishlistID log.Printf("Creating task with wishlist_id: %d", *req.WishlistID) } else { wishlistIDValue = nil log.Printf("Creating task without wishlist_id") } // Подготовка reward_policy: если задача связана с желанием и политика не указана, используем "personal" по умолчанию var rewardPolicyValue interface{} if req.WishlistID != nil { if req.RewardPolicy != nil && (*req.RewardPolicy == "personal" || *req.RewardPolicy == "general") { rewardPolicyValue = *req.RewardPolicy } else { rewardPolicyValue = "personal" // Значение по умолчанию для задач, связанных с желаниями } } else { rewardPolicyValue = nil // NULL для задач, не связанных с желаниями } // Используем условный SQL для обработки NULL значений var insertSQL string var insertArgs []interface{} if repetitionPeriod.Valid { // Для repetition_period выставляем сегодняшнюю дату // Получаем часовой пояс из переменной окружения (по умолчанию 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 } now := time.Now().In(loc) insertSQL = ` INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy, group_name) VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE, $7, $8, $9) RETURNING id ` insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now, wishlistIDValue, rewardPolicyValue, req.GroupName} } else if repetitionDate.Valid { // Вычисляем next_show_at для задачи с repetition_date // Получаем часовой пояс из переменной окружения (по умолчанию 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 } nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc)) if nextShowAt != nil { insertSQL = ` INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy, group_name) VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE, $7, $8, $9) RETURNING id ` insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, wishlistIDValue, rewardPolicyValue, req.GroupName} } else { insertSQL = ` INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy, group_name) VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE, $6, $7, $8) RETURNING id ` insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, wishlistIDValue, rewardPolicyValue, req.GroupName} } } else { // Получаем часовой пояс для задач без повторения 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 } now := time.Now().In(loc) insertSQL = ` INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy, group_name) VALUES ($1, $2, $3, $4, NULL, NULL, $5, 0, FALSE, $6, $7, $8) RETURNING id ` insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, now, wishlistIDValue, rewardPolicyValue, req.GroupName} } err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID) if err != nil { log.Printf("Error creating task: %v", err) // Проверяем, не является ли это ошибкой уникального индекса if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest) return } sendErrorWithCORS(w, fmt.Sprintf("Error creating task: %v", err), http.StatusInternalServerError) return } // Создаем награды для основной задачи for _, rewardReq := range req.Rewards { projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) if err != nil { log.Printf("Error finding project %s: %v", rewardReq.ProjectName, err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } _, err = tx.Exec(` INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) VALUES ($1, $2, $3, $4, $5) `, rewardReq.Position, taskID, projectID, rewardReq.Value, rewardReq.UseProgression) if err != nil { log.Printf("Error creating reward: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating reward: %v", err), http.StatusInternalServerError) return } } // Создаем подзадачи for index, subtaskReq := range req.Subtasks { var subtaskName sql.NullString var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 var subtaskPosition sql.NullInt64 if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} } if subtaskReq.RewardMessage != nil { subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true} } if req.ProgressionBase != nil { subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } // Используем position из запроса, если указан, иначе используем индекс в массиве if subtaskReq.Position != nil { subtaskPosition = sql.NullInt64{Int64: int64(*subtaskReq.Position), Valid: true} } else { subtaskPosition = sql.NullInt64{Int64: int64(index), Valid: true} } var subtaskID int err = tx.QueryRow(` INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted, position) VALUES ($1, $2, $3, $4, $5, 0, FALSE, $6) RETURNING id `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition).Scan(&subtaskID) if err != nil { log.Printf("Error creating subtask: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask: %v", err), http.StatusInternalServerError) return } // Создаем награды для подзадачи for _, rewardReq := range subtaskReq.Rewards { if strings.TrimSpace(rewardReq.ProjectName) == "" { sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) return } projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) if err != nil { log.Printf("Error finding project %s for subtask: %v", rewardReq.ProjectName, err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } _, err = tx.Exec(` INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) VALUES ($1, $2, $3, $4, $5) `, rewardReq.Position, subtaskID, projectID, rewardReq.Value, rewardReq.UseProgression) if err != nil { log.Printf("Error creating subtask reward: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError) return } } } // Если это тест, создаем конфигурацию if req.IsTest { // Валидация: для теста должны быть указаны words_count и хотя бы один словарь if req.WordsCount == nil || *req.WordsCount < 1 { sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest) return } if len(req.DictionaryIDs) == 0 { sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest) return } // Создаем конфигурацию теста var configID int if req.MaxCards != nil { err = tx.QueryRow(` INSERT INTO configs (user_id, words_count, max_cards) VALUES ($1, $2, $3) RETURNING id `, userID, *req.WordsCount, *req.MaxCards).Scan(&configID) } else { err = tx.QueryRow(` INSERT INTO configs (user_id, words_count) VALUES ($1, $2) RETURNING id `, userID, *req.WordsCount).Scan(&configID) } if err != nil { log.Printf("Error creating config: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError) return } // Связываем конфигурацию со словарями for _, dictID := range req.DictionaryIDs { _, err = tx.Exec(` INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2) `, configID, dictID) if err != nil { log.Printf("Error linking dictionary %d to config: %v", dictID, err) sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError) return } } // Обновляем задачу, привязывая config_id _, err = tx.Exec(` UPDATE tasks SET config_id = $1 WHERE id = $2 `, configID, taskID) if err != nil { log.Printf("Error linking config to task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError) return } log.Printf("Created test config %d for task %d", configID, taskID) } // Если это закупка, создаем конфигурацию if req.IsPurchase { if len(req.PurchaseBoards) == 0 { sendErrorWithCORS(w, "At least one board is required for purchase tasks", http.StatusBadRequest) return } var purchaseConfigID int err = tx.QueryRow(` INSERT INTO purchase_configs (user_id) VALUES ($1) RETURNING id `, userID).Scan(&purchaseConfigID) if err != nil { log.Printf("Error creating purchase config: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating purchase config: %v", err), http.StatusInternalServerError) return } for _, pb := range req.PurchaseBoards { _, err = tx.Exec(` INSERT INTO purchase_config_boards (purchase_config_id, board_id, group_name) VALUES ($1, $2, $3) `, purchaseConfigID, pb.BoardID, pb.GroupName) if err != nil { log.Printf("Error linking board %d to purchase config: %v", pb.BoardID, err) sendErrorWithCORS(w, fmt.Sprintf("Error linking board to purchase config: %v", err), http.StatusInternalServerError) return } } _, err = tx.Exec("UPDATE tasks SET purchase_config_id = $1 WHERE id = $2", purchaseConfigID, taskID) if err != nil { log.Printf("Error linking purchase config to task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error linking purchase config to task: %v", err), http.StatusInternalServerError) return } log.Printf("Created purchase config %d for task %d", purchaseConfigID, taskID) } // Коммитим транзакцию 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 } // Обновляем MV для групповых саджестов if req.GroupName != nil && *req.GroupName != "" { if err := a.refreshGroupSuggestionsMV(); err != nil { log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) } } // Возвращаем созданную задачу var createdTask Task var lastCompletedAt sql.NullString var createdRepetitionPeriod sql.NullString var createdRepetitionDate sql.NullString err = a.DB.QueryRow(` SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text, repetition_date FROM tasks WHERE id = $1 `, taskID).Scan( &createdTask.ID, &createdTask.Name, &createdTask.Completed, &lastCompletedAt, &rewardMessage, &progressionBase, &createdRepetitionPeriod, &createdRepetitionDate, ) if err != nil { log.Printf("Error fetching created task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error fetching created task: %v", err), http.StatusInternalServerError) return } if rewardMessage.Valid { createdTask.RewardMessage = &rewardMessage.String } if progressionBase.Valid { createdTask.ProgressionBase = &progressionBase.Float64 } if lastCompletedAt.Valid { createdTask.LastCompletedAt = &lastCompletedAt.String } if createdRepetitionPeriod.Valid { createdTask.RepetitionPeriod = &createdRepetitionPeriod.String } if createdRepetitionDate.Valid { createdTask.RepetitionDate = &createdRepetitionDate.String } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(createdTask) } // updateTaskHandler обновляет существующую задачу func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } // Проверяем владельца var ownerID int err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1", taskID).Scan(&ownerID) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking task ownership: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) return } var req TaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding task request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Валидация if len(strings.TrimSpace(req.Name)) < 1 { sendErrorWithCORS(w, "Task name is required and must be at least 1 character", http.StatusBadRequest) return } // Проверяем, что все rewards имеют project_name for _, reward := range req.Rewards { if strings.TrimSpace(reward.ProjectName) == "" { sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) return } } // Обработка wishlist_id: можно только отвязать (установить в NULL), нельзя привязать // Если req.WishlistID == nil, значит пользователь хочет отвязать (или не трогать) // Если req.WishlistID != nil, игнорируем (нельзя привязать при редактировании) // Получаем текущий wishlist_id задачи var currentWishlistID sql.NullInt64 err = a.DB.QueryRow("SELECT wishlist_id FROM tasks WHERE id = $1", taskID).Scan(¤tWishlistID) if err != nil { log.Printf("Error getting current wishlist_id: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error getting task: %v", err), http.StatusInternalServerError) return } // Определяем новое значение wishlist_id // Если задача была привязана и req.WishlistID == nil, значит отвязываем // Если req.WishlistID != nil, игнорируем (нельзя привязать) var newWishlistID interface{} if currentWishlistID.Valid && req.WishlistID == nil { // Отвязываем от желания newWishlistID = nil } else if currentWishlistID.Valid { // Оставляем текущее значение (нельзя привязать) newWishlistID = currentWishlistID.Int64 } else { // Задача не была привязана, оставляем NULL newWishlistID = nil } // Если задача привязана к желанию, не позволяем устанавливать повторения и прогрессию if currentWishlistID.Valid { if (req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "") || (req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "") { // Проверяем, что это не бесконечная задача (оба поля = 0) isPeriodZero := req.RepetitionPeriod != nil && (strings.TrimSpace(*req.RepetitionPeriod) == "0 day" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionPeriod), "0 ")) isDateZero := req.RepetitionDate != nil && (strings.TrimSpace(*req.RepetitionDate) == "0 week" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionDate), "0 ")) if !isPeriodZero || !isDateZero { sendErrorWithCORS(w, "Tasks linked to wishlist items cannot be periodic", http.StatusBadRequest) return } } // Задачи, привязанные к желанию, не могут иметь прогрессию if req.ProgressionBase != nil { sendErrorWithCORS(w, "Tasks linked to wishlist items cannot have progression", http.StatusBadRequest) return } } // Начинаем транзакцию tx, err := a.DB.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) return } defer tx.Rollback() // Обновляем основную задачу var rewardMessage sql.NullString var progressionBase sql.NullFloat64 var repetitionPeriod sql.NullString var repetitionDate sql.NullString if req.RewardMessage != nil { rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true} } if req.ProgressionBase != nil { progressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } if req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "" { repetitionPeriod = sql.NullString{String: strings.TrimSpace(*req.RepetitionPeriod), Valid: true} log.Printf("Updating task %d with repetition_period: %s", taskID, repetitionPeriod.String) } else { log.Printf("Updating task %d without repetition_period (req.RepetitionPeriod: %v)", taskID, req.RepetitionPeriod) } if req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "" { repetitionDate = sql.NullString{String: strings.TrimSpace(*req.RepetitionDate), Valid: true} log.Printf("Updating task %d with repetition_date: %s", taskID, repetitionDate.String) } // Подготовка reward_policy: если задача связана с желанием и политика не указана, используем "personal" по умолчанию var rewardPolicyValue interface{} if newWishlistID != nil { // Если reward_policy явно указан в запросе, используем его if req.RewardPolicy != nil && (*req.RewardPolicy == "personal" || *req.RewardPolicy == "general") { rewardPolicyValue = *req.RewardPolicy } else if req.RewardPolicy == nil { // Если reward_policy не указан в запросе (undefined), сохраняем текущее значение из БД // Это важно для случаев, когда обновляются другие поля, но reward_policy не должен меняться var currentRewardPolicy sql.NullString err = a.DB.QueryRow("SELECT reward_policy FROM tasks WHERE id = $1", taskID).Scan(¤tRewardPolicy) if err == nil && currentRewardPolicy.Valid { rewardPolicyValue = currentRewardPolicy.String } else { // Если в БД нет значения, используем "personal" по умолчанию rewardPolicyValue = "personal" } } } else { rewardPolicyValue = nil // NULL для задач, не связанных с желаниями } // Обновляем задачу без изменения next_show_at (он меняется только при завершении/откладывании) var updateSQL string var updateArgs []interface{} if repetitionPeriod.Valid { updateSQL = ` UPDATE tasks SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, wishlist_id = $5, reward_policy = $6, group_name = $7 WHERE id = $8 ` updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, newWishlistID, rewardPolicyValue, req.GroupName, taskID} } else if repetitionDate.Valid { updateSQL = ` UPDATE tasks SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, wishlist_id = $5, reward_policy = $6, group_name = $7 WHERE id = $8 ` updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, newWishlistID, rewardPolicyValue, req.GroupName, taskID} } else { updateSQL = ` UPDATE tasks SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = NULL, wishlist_id = $4, reward_policy = $5, group_name = $6 WHERE id = $7 ` updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, newWishlistID, rewardPolicyValue, req.GroupName, taskID} } _, err = tx.Exec(updateSQL, updateArgs...) if err != nil { log.Printf("Error updating task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating task: %v", err), http.StatusInternalServerError) return } // Удаляем старые награды основной задачи _, err = tx.Exec("DELETE FROM reward_configs WHERE task_id = $1", taskID) if err != nil { log.Printf("Error deleting old rewards: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting old rewards: %v", err), http.StatusInternalServerError) return } // Вставляем новые награды for _, rewardReq := range req.Rewards { projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) if err != nil { log.Printf("Error finding project %s: %v", rewardReq.ProjectName, err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } _, err = tx.Exec(` INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) VALUES ($1, $2, $3, $4, $5) `, rewardReq.Position, taskID, projectID, rewardReq.Value, rewardReq.UseProgression) if err != nil { log.Printf("Error creating reward: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating reward: %v", err), http.StatusInternalServerError) return } } // Получаем список текущих подзадач currentSubtaskIDs := make(map[int]bool) rows, err := tx.Query("SELECT id FROM tasks WHERE parent_task_id = $1 AND deleted = FALSE", taskID) if err == nil { for rows.Next() { var id int if err := rows.Scan(&id); err == nil { currentSubtaskIDs[id] = true } } rows.Close() } // Обрабатываем подзадачи из запроса subtaskIDsInRequest := make(map[int]bool) for index, subtaskReq := range req.Subtasks { if subtaskReq.ID != nil { subtaskIDsInRequest[*subtaskReq.ID] = true // Обновляем существующую подзадачу var subtaskName sql.NullString var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 var subtaskPosition sql.NullInt64 if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} } if subtaskReq.RewardMessage != nil { subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true} } if req.ProgressionBase != nil { subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } // Используем position из запроса, если указан, иначе используем индекс в массиве if subtaskReq.Position != nil { subtaskPosition = sql.NullInt64{Int64: int64(*subtaskReq.Position), Valid: true} } else { subtaskPosition = sql.NullInt64{Int64: int64(index), Valid: true} } _, err = tx.Exec(` UPDATE tasks SET name = $1, reward_message = $2, progression_base = $3, position = $4 WHERE id = $5 AND parent_task_id = $6 `, subtaskName, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition, *subtaskReq.ID, taskID) if err != nil { log.Printf("Error updating subtask: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating subtask: %v", err), http.StatusInternalServerError) return } // Удаляем старые награды подзадачи _, err = tx.Exec("DELETE FROM reward_configs WHERE task_id = $1", *subtaskReq.ID) if err != nil { log.Printf("Error deleting old subtask rewards: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting old subtask rewards: %v", err), http.StatusInternalServerError) return } // Вставляем новые награды подзадачи for _, rewardReq := range subtaskReq.Rewards { if strings.TrimSpace(rewardReq.ProjectName) == "" { sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) return } projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) if err != nil { log.Printf("Error finding project %s for subtask: %v", rewardReq.ProjectName, err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } _, err = tx.Exec(` INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) VALUES ($1, $2, $3, $4, $5) `, rewardReq.Position, *subtaskReq.ID, projectID, rewardReq.Value, rewardReq.UseProgression) if err != nil { log.Printf("Error creating subtask reward: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError) return } } } else { // Создаем новую подзадачу var subtaskName sql.NullString var subtaskRewardMessage sql.NullString var subtaskProgressionBase sql.NullFloat64 var subtaskPosition sql.NullInt64 if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" { subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true} } if subtaskReq.RewardMessage != nil { subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true} } if req.ProgressionBase != nil { subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true} } // Используем position из запроса, если указан, иначе используем индекс в массиве if subtaskReq.Position != nil { subtaskPosition = sql.NullInt64{Int64: int64(*subtaskReq.Position), Valid: true} } else { subtaskPosition = sql.NullInt64{Int64: int64(index), Valid: true} } var subtaskID int err = tx.QueryRow(` INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted, position) VALUES ($1, $2, $3, $4, $5, 0, FALSE, $6) RETURNING id `, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition).Scan(&subtaskID) if err != nil { log.Printf("Error creating subtask: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask: %v", err), http.StatusInternalServerError) return } // Создаем награды для новой подзадачи for _, rewardReq := range subtaskReq.Rewards { if strings.TrimSpace(rewardReq.ProjectName) == "" { sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest) return } projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID) if err != nil { log.Printf("Error finding project %s for new subtask: %v", rewardReq.ProjectName, err) sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } _, err = tx.Exec(` INSERT INTO reward_configs (position, task_id, project_id, value, use_progression) VALUES ($1, $2, $3, $4, $5) `, rewardReq.Position, subtaskID, projectID, rewardReq.Value, rewardReq.UseProgression) if err != nil { log.Printf("Error creating subtask reward: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError) return } } } } // Помечаем подзадачи, которые были в БД, но не пришли в запросе, как deleted for subtaskID := range currentSubtaskIDs { if !subtaskIDsInRequest[subtaskID] { _, err = tx.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1", subtaskID) if err != nil { log.Printf("Error marking subtask as deleted: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error marking subtask as deleted: %v", err), http.StatusInternalServerError) return } } } // Получаем текущий config_id задачи var currentConfigID sql.NullInt64 err = tx.QueryRow("SELECT config_id FROM tasks WHERE id = $1", taskID).Scan(¤tConfigID) if err != nil { log.Printf("Error getting current config_id: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error getting task config: %v", err), http.StatusInternalServerError) return } // Обработка конфигурации теста if req.IsTest { // Валидация: для теста должны быть указаны words_count и хотя бы один словарь if req.WordsCount == nil || *req.WordsCount < 1 { sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest) return } if len(req.DictionaryIDs) == 0 { sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest) return } if currentConfigID.Valid { // Обновляем существующую конфигурацию if req.MaxCards != nil { _, err = tx.Exec(` UPDATE configs SET words_count = $1, max_cards = $2 WHERE id = $3 `, *req.WordsCount, *req.MaxCards, currentConfigID.Int64) } else { _, err = tx.Exec(` UPDATE configs SET words_count = $1, max_cards = NULL WHERE id = $2 `, *req.WordsCount, currentConfigID.Int64) } if err != nil { log.Printf("Error updating config: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating config: %v", err), http.StatusInternalServerError) return } // Обновляем связи со словарями _, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64) if err != nil { log.Printf("Error deleting config dictionaries: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating config dictionaries: %v", err), http.StatusInternalServerError) return } for _, dictID := range req.DictionaryIDs { _, err = tx.Exec(` INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2) `, currentConfigID.Int64, dictID) if err != nil { log.Printf("Error linking dictionary %d to config: %v", dictID, err) sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError) return } } } else { // Создаем новую конфигурацию для существующей задачи var newConfigID int if req.MaxCards != nil { err = tx.QueryRow(` INSERT INTO configs (user_id, words_count, max_cards) VALUES ($1, $2, $3) RETURNING id `, userID, *req.WordsCount, *req.MaxCards).Scan(&newConfigID) } else { err = tx.QueryRow(` INSERT INTO configs (user_id, words_count) VALUES ($1, $2) RETURNING id `, userID, *req.WordsCount).Scan(&newConfigID) } if err != nil { log.Printf("Error creating config: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError) return } for _, dictID := range req.DictionaryIDs { _, err = tx.Exec(` INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2) `, newConfigID, dictID) if err != nil { log.Printf("Error linking dictionary %d to config: %v", dictID, err) sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError) return } } _, err = tx.Exec("UPDATE tasks SET config_id = $1 WHERE id = $2", newConfigID, taskID) if err != nil { log.Printf("Error linking config to task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError) return } } } else if currentConfigID.Valid { // Задача перестала быть тестом - удаляем конфигурацию _, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64) if err != nil { log.Printf("Error deleting config dictionaries: %v", err) } _, err = tx.Exec("DELETE FROM configs WHERE id = $1", currentConfigID.Int64) if err != nil { log.Printf("Error deleting config: %v", err) } _, err = tx.Exec("UPDATE tasks SET config_id = NULL WHERE id = $1", taskID) if err != nil { log.Printf("Error unlinking config from task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error unlinking config from task: %v", err), http.StatusInternalServerError) return } } // Обработка конфигурации закупки var currentPurchaseConfigID sql.NullInt64 err = tx.QueryRow("SELECT purchase_config_id FROM tasks WHERE id = $1", taskID).Scan(¤tPurchaseConfigID) if err != nil { log.Printf("Error getting current purchase_config_id: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error getting task purchase config: %v", err), http.StatusInternalServerError) return } if req.IsPurchase { if len(req.PurchaseBoards) == 0 { sendErrorWithCORS(w, "At least one board is required for purchase tasks", http.StatusBadRequest) return } if currentPurchaseConfigID.Valid { // Обновляем существующую конфигурацию - удаляем старые связи и создаем новые _, err = tx.Exec("DELETE FROM purchase_config_boards WHERE purchase_config_id = $1", currentPurchaseConfigID.Int64) if err != nil { log.Printf("Error deleting purchase config boards: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating purchase config: %v", err), http.StatusInternalServerError) return } for _, pb := range req.PurchaseBoards { _, err = tx.Exec(` INSERT INTO purchase_config_boards (purchase_config_id, board_id, group_name) VALUES ($1, $2, $3) `, currentPurchaseConfigID.Int64, pb.BoardID, pb.GroupName) if err != nil { log.Printf("Error linking board %d to purchase config: %v", pb.BoardID, err) sendErrorWithCORS(w, fmt.Sprintf("Error linking board to purchase config: %v", err), http.StatusInternalServerError) return } } } else { // Создаем новую конфигурацию закупки var newPurchaseConfigID int err = tx.QueryRow(` INSERT INTO purchase_configs (user_id) VALUES ($1) RETURNING id `, userID).Scan(&newPurchaseConfigID) if err != nil { log.Printf("Error creating purchase config: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating purchase config: %v", err), http.StatusInternalServerError) return } for _, pb := range req.PurchaseBoards { _, err = tx.Exec(` INSERT INTO purchase_config_boards (purchase_config_id, board_id, group_name) VALUES ($1, $2, $3) `, newPurchaseConfigID, pb.BoardID, pb.GroupName) if err != nil { log.Printf("Error linking board %d to purchase config: %v", pb.BoardID, err) sendErrorWithCORS(w, fmt.Sprintf("Error linking board to purchase config: %v", err), http.StatusInternalServerError) return } } _, err = tx.Exec("UPDATE tasks SET purchase_config_id = $1 WHERE id = $2", newPurchaseConfigID, taskID) if err != nil { log.Printf("Error linking purchase config to task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error linking purchase config to task: %v", err), http.StatusInternalServerError) return } } } else if currentPurchaseConfigID.Valid { // Задача перестала быть закупкой - удаляем конфигурацию _, err = tx.Exec("DELETE FROM purchase_config_boards WHERE purchase_config_id = $1", currentPurchaseConfigID.Int64) if err != nil { log.Printf("Error deleting purchase config boards: %v", err) } _, err = tx.Exec("DELETE FROM purchase_configs WHERE id = $1", currentPurchaseConfigID.Int64) if err != nil { log.Printf("Error deleting purchase config: %v", err) } _, err = tx.Exec("UPDATE tasks SET purchase_config_id = NULL WHERE id = $1", taskID) if err != nil { log.Printf("Error unlinking purchase config from task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error unlinking purchase config from task: %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 } // Обновляем MV для групповых саджестов if req.GroupName != nil && *req.GroupName != "" { if err := a.refreshGroupSuggestionsMV(); err != nil { log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) } } // Возвращаем обновленную задачу var updatedTask Task var lastCompletedAt sql.NullString var updatedRepetitionPeriod sql.NullString var updatedRepetitionDate sql.NullString err = a.DB.QueryRow(` SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text, repetition_date FROM tasks WHERE id = $1 `, taskID).Scan( &updatedTask.ID, &updatedTask.Name, &updatedTask.Completed, &lastCompletedAt, &rewardMessage, &progressionBase, &updatedRepetitionPeriod, &updatedRepetitionDate, ) if err != nil { log.Printf("Error fetching updated task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error fetching updated task: %v", err), http.StatusInternalServerError) return } if rewardMessage.Valid { updatedTask.RewardMessage = &rewardMessage.String } if progressionBase.Valid { updatedTask.ProgressionBase = &progressionBase.Float64 } if lastCompletedAt.Valid { updatedTask.LastCompletedAt = &lastCompletedAt.String } if updatedRepetitionPeriod.Valid { updatedTask.RepetitionPeriod = &updatedRepetitionPeriod.String } if updatedRepetitionDate.Valid { updatedTask.RepetitionDate = &updatedRepetitionDate.String } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(updatedTask) } // saveTaskDraftHandler сохраняет или обновляет драфт задачи func (a *App) saveTaskDraftHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } // Проверяем владельца задачи var ownerID int err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking task ownership: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) return } var req SaveDraftRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding save draft request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Если авто-выполнение включено и progression_value не задан — подставляем progression_base задачи if req.AutoComplete != nil && *req.AutoComplete && req.ProgressionValue == nil && (req.ClearProgressionValue == nil || !*req.ClearProgressionValue) { var taskProgressionBase sql.NullFloat64 if pbErr := a.DB.QueryRow("SELECT progression_base FROM tasks WHERE id = $1", taskID).Scan(&taskProgressionBase); pbErr != nil { log.Printf("Error fetching task progression_base: %v", pbErr) sendErrorWithCORS(w, fmt.Sprintf("Error fetching task progression_base: %v", pbErr), http.StatusInternalServerError) return } if taskProgressionBase.Valid { req.ProgressionValue = &taskProgressionBase.Float64 } } // Начинаем транзакцию 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 draftID int err = tx.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID) var progressionValue sql.NullFloat64 clearProgression := req.ClearProgressionValue != nil && *req.ClearProgressionValue if req.ProgressionValue != nil { progressionValue = sql.NullFloat64{Float64: *req.ProgressionValue, Valid: true} } if err == sql.ErrNoRows { // Создаем новый драфт autoComplete := false if req.AutoComplete != nil { autoComplete = *req.AutoComplete } err = tx.QueryRow(` INSERT INTO task_drafts (task_id, user_id, progression_value, auto_complete, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW()) RETURNING id `, taskID, userID, progressionValue, autoComplete).Scan(&draftID) if err != nil { log.Printf("Error creating draft: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating draft: %v", err), http.StatusInternalServerError) return } } else if err != nil { log.Printf("Error checking draft existence: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking draft existence: %v", err), http.StatusInternalServerError) return } else { // Обновляем существующий драфт // Обновляем только те поля, которые переданы if req.ProgressionValue != nil || req.AutoComplete != nil || clearProgression { if clearProgression { // Сбрасываем прогрессию в null if req.AutoComplete != nil { _, err = tx.Exec(` UPDATE task_drafts SET progression_value = NULL, auto_complete = $1, updated_at = NOW() WHERE id = $2 `, *req.AutoComplete, draftID) } else { _, err = tx.Exec(` UPDATE task_drafts SET progression_value = NULL, updated_at = NOW() WHERE id = $1 `, draftID) } } else if req.AutoComplete != nil { // Обновляем оба поля _, err = tx.Exec(` UPDATE task_drafts SET progression_value = COALESCE($1, progression_value), auto_complete = $2, updated_at = NOW() WHERE id = $3 `, progressionValue, *req.AutoComplete, draftID) } else { // Обновляем только progression_value _, err = tx.Exec(` UPDATE task_drafts SET progression_value = $1, updated_at = NOW() WHERE id = $2 `, progressionValue, draftID) } if err != nil { log.Printf("Error updating draft: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating draft: %v", err), http.StatusInternalServerError) return } } // Удаляем и обновляем записи подзадач только если они были переданы if req.ChildrenTaskIDs != nil { _, err = tx.Exec("DELETE FROM task_draft_subtasks WHERE task_draft_id = $1", draftID) if err != nil { log.Printf("Error deleting old draft subtasks: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting old draft subtasks: %v", err), http.StatusInternalServerError) return } } } // Вставляем новые записи подзадач (только checked подзадачи) if req.ChildrenTaskIDs != nil && len(*req.ChildrenTaskIDs) > 0 { childrenIDs := *req.ChildrenTaskIDs // Проверяем, что все подзадачи принадлежат этой задаче placeholders := make([]string, len(childrenIDs)) args := make([]interface{}, len(childrenIDs)+1) args[0] = taskID for i, id := range childrenIDs { placeholders[i] = fmt.Sprintf("$%d", i+2) args[i+1] = id } query := fmt.Sprintf(` SELECT id FROM tasks WHERE parent_task_id = $1 AND id IN (%s) AND deleted = FALSE `, strings.Join(placeholders, ",")) validSubtaskRows, err := tx.Query(query, args...) if err != nil { log.Printf("Error validating subtasks: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error validating subtasks: %v", err), http.StatusInternalServerError) return } defer validSubtaskRows.Close() validSubtaskIDs := make(map[int]bool) for validSubtaskRows.Next() { var id int if err := validSubtaskRows.Scan(&id); err == nil { validSubtaskIDs[id] = true } } // Вставляем только валидные подзадачи for _, subtaskID := range childrenIDs { if validSubtaskIDs[subtaskID] { _, err = tx.Exec(` INSERT INTO task_draft_subtasks (task_draft_id, subtask_id) VALUES ($1, $2) ON CONFLICT (task_draft_id, subtask_id) DO NOTHING `, draftID, subtaskID) if err != nil { log.Printf("Error inserting draft subtask: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error inserting draft subtask: %v", err), http.StatusInternalServerError) return } } } } // Коммитим транзакцию if err = tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Draft saved successfully", }) } // deleteTaskHandler удаляет задачу (помечает как deleted) func (a *App) deleteTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } // Проверяем владельца var ownerID int err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1", taskID).Scan(&ownerID) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking task ownership: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) return } // Помечаем задачу как удаленную _, err = a.DB.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1 AND user_id = $2", taskID, userID) if err != nil { log.Printf("Error deleting task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting task: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Task deleted successfully", }) } // executeTask выполняет задачу (вынесенная логика) // Удаляет драфт перед выполнением и выполняет всю логику выполнения задачи func (a *App) executeTask(taskID int, userID int, req CompleteTaskRequest) error { // Удаляем драфт перед выполнением (если есть) _, err := a.DB.Exec(`DELETE FROM task_drafts WHERE task_id = $1`, taskID) if err != nil { log.Printf("Error deleting draft for task %d: %v", taskID, err) // Не возвращаем ошибку, продолжаем выполнение } // Получаем задачу и проверяем владельца var task Task var rewardMessage sql.NullString var progressionBase sql.NullFloat64 var repetitionPeriod sql.NullString var repetitionDate sql.NullString var ownerID int var wishlistID sql.NullInt64 err = a.DB.QueryRow(` SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id, wishlist_id FROM tasks WHERE id = $1 AND deleted = FALSE `, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID, &wishlistID) if err == sql.ErrNoRows { return fmt.Errorf("task not found") } if err != nil { log.Printf("Error querying task: %v", err) return fmt.Errorf("error querying task: %v", err) } if ownerID != userID { return fmt.Errorf("task not found") } // Проверяем, что желание разблокировано (если задача связана с желанием) if wishlistID.Valid { unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID) if err != nil { log.Printf("Error checking wishlist unlock status: %v", err) return fmt.Errorf("error checking wishlist unlock status: %v", err) } if !unlocked { return fmt.Errorf("cannot complete task: wishlist item is not unlocked") } } // Валидация: если progression_base != null, то value обязателен if progressionBase.Valid && req.Value == nil { return fmt.Errorf("value is required when progression_base is set") } if rewardMessage.Valid { task.RewardMessage = &rewardMessage.String } if progressionBase.Valid { task.ProgressionBase = &progressionBase.Float64 } // Получаем награды основной задачи rewardRows, err := a.DB.Query(` SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id = $1 ORDER BY rc.position `, taskID) if err != nil { log.Printf("Error querying rewards: %v", err) return fmt.Errorf("error querying rewards: %v", err) } defer rewardRows.Close() rewards := make([]Reward, 0) for rewardRows.Next() { var reward Reward err := rewardRows.Scan(&reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning reward: %v", err) continue } rewards = append(rewards, reward) } // Вычисляем score для каждой награды и формируем строки для подстановки rewardStrings := make(map[int]string) var progressionBasePtr *float64 if progressionBase.Valid { progressionBasePtr = &progressionBase.Float64 } for _, reward := range rewards { score := calculateRewardScore(reward, req.Value, progressionBasePtr) // Формируем строку награды var rewardStr string if score >= 0 { rewardStr = fmt.Sprintf("**%s+%.4g**", reward.ProjectName, score) } else { rewardStr = fmt.Sprintf("**%s-%.4g**", reward.ProjectName, math.Abs(score)) } rewardStrings[reward.Position] = rewardStr } // Функция для замены плейсхолдеров в сообщении награды replaceRewardPlaceholders := func(message string, rewardStrings map[int]string, taskName string, subtaskName string) string { result := message // Сначала сохраняем экранированные плейсхолдеры \$0, \$1 и т.д. во временные маркеры escapedMarkers := make(map[string]string) for i := 0; i < 100; i++ { escaped := fmt.Sprintf(`\$%d`, i) marker := fmt.Sprintf(`__ESCAPED_DOLLAR_%d__`, i) if strings.Contains(result, escaped) { escapedMarkers[marker] = escaped result = strings.ReplaceAll(result, escaped, marker) } } // Заменяем $subtaskName именем подзадачи (если задано) if subtaskName != "" { result = strings.ReplaceAll(result, "$subtaskName", subtaskName) } // Заменяем $name именем задачи result = strings.ReplaceAll(result, "$name", taskName) // Заменяем ${0}, ${1}, и т.д. for i := 0; i < 100; i++ { // Максимум 100 плейсхолдеров placeholder := fmt.Sprintf("${%d}", i) if rewardStr, ok := rewardStrings[i]; ok { result = strings.ReplaceAll(result, placeholder, rewardStr) } } // Затем заменяем $0, $1, и т.д. (экранированные уже защищены маркерами) // Ищем $N, где после N не идет еще одна цифра (чтобы не заменить $10 при поиске $1) // Go regexp не поддерживает lookahead, поэтому заменяем с конца (от больших чисел к меньшим) for i := 99; i >= 0; i-- { if rewardStr, ok := rewardStrings[i]; ok { searchStr := fmt.Sprintf("$%d", i) // Ищем все вхождения с конца строки for { idx := strings.LastIndex(result, searchStr) if idx == -1 { break } // Проверяем, что после $N не идет еще одна цифра afterIdx := idx + len(searchStr) if afterIdx >= len(result) || result[afterIdx] < '0' || result[afterIdx] > '9' { // Можно заменить result = result[:idx] + rewardStr + result[afterIdx:] } else { // После $N идет еще цифра (например, $10), пропускаем break } } } } // Восстанавливаем экранированные доллары из временных маркеров for marker, escaped := range escapedMarkers { result = strings.ReplaceAll(result, marker, escaped) } return result } // Подставляем в reward_message основной задачи var mainTaskMessage string if task.RewardMessage != nil && *task.RewardMessage != "" { mainTaskMessage = replaceRewardPlaceholders(*task.RewardMessage, rewardStrings, task.Name, "") } else { // Если reward_message пустой, используем имя задачи mainTaskMessage = task.Name } // Получаем выбранные подзадачи (только с непустым reward_message и deleted = FALSE) subtaskMessages := make([]string, 0) if len(req.ChildrenTaskIDs) > 0 { placeholders := make([]string, len(req.ChildrenTaskIDs)) args := make([]interface{}, len(req.ChildrenTaskIDs)+1) args[0] = taskID for i, id := range req.ChildrenTaskIDs { placeholders[i] = fmt.Sprintf("$%d", i+2) args[i+1] = id } query := fmt.Sprintf(` SELECT id, name, reward_message, progression_base FROM tasks WHERE parent_task_id = $1 AND id IN (%s) AND deleted = FALSE `, strings.Join(placeholders, ",")) subtaskRows, err := a.DB.Query(query, args...) if err != nil { log.Printf("Error querying subtasks: %v", err) } else { // Собираем подзадачи с reward_message type subtaskInfo struct { id int name string rewardMessage string progressionBase sql.NullFloat64 } var subtasks []subtaskInfo subtaskIDs := make([]int, 0) for subtaskRows.Next() { var st subtaskInfo var subtaskRewardMessage sql.NullString err := subtaskRows.Scan(&st.id, &st.name, &subtaskRewardMessage, &st.progressionBase) if err != nil { log.Printf("Error scanning subtask: %v", err) continue } if !subtaskRewardMessage.Valid || subtaskRewardMessage.String == "" { continue } st.rewardMessage = subtaskRewardMessage.String subtasks = append(subtasks, st) subtaskIDs = append(subtaskIDs, st.id) } subtaskRows.Close() // Батчевый запрос наград для всех подзадач за один раз subtaskRewardsMap := make(map[int][]Reward) if len(subtaskIDs) > 0 { idArgs := make([]interface{}, len(subtaskIDs)) idPlaceholders := make([]string, len(subtaskIDs)) for i, id := range subtaskIDs { idArgs[i] = id idPlaceholders[i] = fmt.Sprintf("$%d", i+1) } batchQuery := fmt.Sprintf(` SELECT rc.task_id, rc.position, p.name AS project_name, rc.value, rc.use_progression FROM reward_configs rc JOIN projects p ON rc.project_id = p.id WHERE rc.task_id IN (%s) ORDER BY rc.task_id, rc.position `, strings.Join(idPlaceholders, ",")) rewardRows, err := a.DB.Query(batchQuery, idArgs...) if err != nil { log.Printf("Error querying subtask rewards batch: %v", err) } else { for rewardRows.Next() { var stTaskID int var reward Reward err := rewardRows.Scan(&stTaskID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression) if err != nil { log.Printf("Error scanning subtask reward: %v", err) continue } subtaskRewardsMap[stTaskID] = append(subtaskRewardsMap[stTaskID], reward) } rewardRows.Close() } } // Формируем сообщения для каждой подзадачи for _, st := range subtasks { subtaskRewardStrings := make(map[int]string) var subtaskProgressionBasePtr *float64 if st.progressionBase.Valid { subtaskProgressionBasePtr = &st.progressionBase.Float64 } else if progressionBase.Valid { subtaskProgressionBasePtr = &progressionBase.Float64 } for _, reward := range subtaskRewardsMap[st.id] { score := calculateRewardScore(reward, req.Value, subtaskProgressionBasePtr) var rewardStr string if score >= 0 { rewardStr = fmt.Sprintf("**%s+%.4g**", reward.ProjectName, score) } else { rewardStr = fmt.Sprintf("**%s-%.4g**", reward.ProjectName, math.Abs(score)) } subtaskRewardStrings[reward.Position] = rewardStr } subtaskMessage := replaceRewardPlaceholders(st.rewardMessage, subtaskRewardStrings, task.Name, st.name) subtaskMessages = append(subtaskMessages, subtaskMessage) } } } // Формируем итоговое сообщение var finalMessage strings.Builder finalMessage.WriteString(mainTaskMessage) for _, subtaskMsg := range subtaskMessages { finalMessage.WriteString("\n + ") finalMessage.WriteString(subtaskMsg) } // Отправляем сообщение через processMessage асинхронно, чтобы не блокировать ответ userIDPtr := &userID finalMessageStr := finalMessage.String() go func() { _, err := a.processMessage(finalMessageStr, userIDPtr) if err != nil { log.Printf("Error sending message to Telegram: %v", err) } }() // Обновляем completed и last_completed_at для основной задачи // Если repetition_date установлен, вычисляем next_show_at // Если repetition_period не установлен и repetition_date не установлен, помечаем задачу как удаленную // Если repetition_period = "0 day" (или любое значение с 0), не обновляем last_completed_at // Проверяем наличие repetition_date (используем COALESCE, поэтому пустая строка означает отсутствие) hasRepetitionDate := repetitionDate.Valid && strings.TrimSpace(repetitionDate.String) != "" if hasRepetitionDate { // Есть repetition_date - вычисляем следующую дату показа // Получаем часовой пояс из переменной окружения (по умолчанию 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 } nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc)) if nextShowAt != nil { _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW(), next_show_at = $2 WHERE id = $1 `, taskID, nextShowAt) } else { // Если не удалось вычислить дату, обновляем как обычно _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL WHERE id = $1 `, taskID) } } else if repetitionPeriod.Valid { // Проверяем, является ли период нулевым (начинается с "0 ") periodStr := strings.TrimSpace(repetitionPeriod.String) isZeroPeriod := strings.HasPrefix(periodStr, "0 ") || periodStr == "0" if isZeroPeriod { // Период = 0: обновляем только счетчик, но не last_completed_at // Задача никогда не будет переноситься в выполненные _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, next_show_at = NULL WHERE id = $1 `, taskID) } else { // Обычный период: обновляем счетчик и last_completed_at, вычисляем next_show_at // next_show_at = last_completed_at + repetition_period // Получаем часовой пояс из переменной окружения (по умолчанию 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 } now := time.Now().In(loc) log.Printf("Calculating next_show_at for task %d: repetition_period='%s', fromDate=%v (timezone: %s)", taskID, repetitionPeriod.String, now, timezoneStr) nextShowAt := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, now) if nextShowAt != nil { log.Printf("Calculated next_show_at for task %d: %v", taskID, *nextShowAt) _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW(), next_show_at = $2 WHERE id = $1 `, taskID, nextShowAt) } else { log.Printf("Failed to calculate next_show_at for task %d: repetition_period='%s' returned nil", taskID, repetitionPeriod.String) // Если не удалось вычислить дату, обновляем как обычно _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL WHERE id = $1 `, taskID) } } } else { _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL, deleted = TRUE WHERE id = $1 `, taskID) } if err != nil { log.Printf("Error updating task completion: %v", err) return fmt.Errorf("error updating task completion: %v", err) } // Обновляем выбранные подзадачи if len(req.ChildrenTaskIDs) > 0 { placeholders := make([]string, len(req.ChildrenTaskIDs)) args := make([]interface{}, len(req.ChildrenTaskIDs)) for i, id := range req.ChildrenTaskIDs { placeholders[i] = fmt.Sprintf("$%d", i+1) args[i] = id } query := fmt.Sprintf(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW() WHERE id IN (%s) AND deleted = FALSE `, strings.Join(placeholders, ",")) _, err = a.DB.Exec(query, args...) if err != nil { log.Printf("Error updating subtasks completion: %v", err) // Не возвращаем ошибку, основная задача уже обновлена } } // Если задача связана с желанием, завершаем желание и обрабатываем политику награждения if wishlistID.Valid { // Завершаем желание _, completeErr := a.DB.Exec(` UPDATE wishlist_items SET completed = TRUE, updated_at = NOW() WHERE id = $1 AND completed = FALSE `, wishlistID.Int64) if completeErr != nil { log.Printf("Error completing wishlist item %d: %v", wishlistID.Int64, completeErr) // Не возвращаем ошибку, задача уже выполнена } else { log.Printf("Wishlist item %d completed automatically after task %d completion", wishlistID.Int64, taskID) // Обрабатываем политику награждения для всех задач, связанных с этим желанием // Исключаем задачу, которая была закрыта (taskID), чтобы не обрабатывать её повторно a.processWishlistRewardPolicy(int(wishlistID.Int64), taskID) } } return nil } // completeTaskAtEndOfDayHandler устанавливает автовыполнение задачи в конце дня func (a *App) completeTaskAtEndOfDayHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } // Проверяем владельца задачи var ownerID int err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking task ownership: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) return } var req SaveDraftRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding save draft request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Устанавливаем auto_complete = true autoCompleteTrue := true req.AutoComplete = &autoCompleteTrue // Если progression_value не задан — подставляем progression_base задачи if req.ProgressionValue == nil && (req.ClearProgressionValue == nil || !*req.ClearProgressionValue) { var taskProgressionBase sql.NullFloat64 if pbErr := a.DB.QueryRow("SELECT progression_base FROM tasks WHERE id = $1", taskID).Scan(&taskProgressionBase); pbErr != nil { log.Printf("Error fetching task progression_base: %v", pbErr) sendErrorWithCORS(w, fmt.Sprintf("Error fetching task progression_base: %v", pbErr), http.StatusInternalServerError) return } if taskProgressionBase.Valid { req.ProgressionValue = &taskProgressionBase.Float64 } } // Используем ту же логику что и saveTaskDraftHandler // Начинаем транзакцию 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 draftID int err = tx.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID) var progressionValue sql.NullFloat64 clearProgression := req.ClearProgressionValue != nil && *req.ClearProgressionValue if req.ProgressionValue != nil { progressionValue = sql.NullFloat64{Float64: *req.ProgressionValue, Valid: true} } if err == sql.ErrNoRows { // Создаем новый драфт err = tx.QueryRow(` INSERT INTO task_drafts (task_id, user_id, progression_value, auto_complete, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW()) RETURNING id `, taskID, userID, progressionValue, *req.AutoComplete).Scan(&draftID) if err != nil { log.Printf("Error creating draft: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating draft: %v", err), http.StatusInternalServerError) return } } else if err != nil { log.Printf("Error checking draft existence: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking draft existence: %v", err), http.StatusInternalServerError) return } else { // Обновляем существующий драфт с auto_complete = true if clearProgression { _, err = tx.Exec(` UPDATE task_drafts SET progression_value = NULL, auto_complete = $1, updated_at = NOW() WHERE id = $2 `, *req.AutoComplete, draftID) } else { _, err = tx.Exec(` UPDATE task_drafts SET progression_value = $1, auto_complete = $2, updated_at = NOW() WHERE id = $3 `, progressionValue, *req.AutoComplete, draftID) } if err != nil { log.Printf("Error updating draft: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating draft: %v", err), http.StatusInternalServerError) return } // Удаляем все старые записи подзадач только если они были переданы if req.ChildrenTaskIDs != nil { _, err = tx.Exec("DELETE FROM task_draft_subtasks WHERE task_draft_id = $1", draftID) if err != nil { log.Printf("Error deleting old draft subtasks: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting old draft subtasks: %v", err), http.StatusInternalServerError) return } } } // Вставляем новые записи подзадач (только checked подзадачи) if req.ChildrenTaskIDs != nil && len(*req.ChildrenTaskIDs) > 0 { childrenIDs := *req.ChildrenTaskIDs // Проверяем, что все подзадачи принадлежат этой задаче placeholders := make([]string, len(childrenIDs)) args := make([]interface{}, len(childrenIDs)+1) args[0] = taskID for i, id := range childrenIDs { placeholders[i] = fmt.Sprintf("$%d", i+2) args[i+1] = id } query := fmt.Sprintf(` SELECT id FROM tasks WHERE parent_task_id = $1 AND id IN (%s) AND deleted = FALSE `, strings.Join(placeholders, ",")) validSubtaskRows, err := tx.Query(query, args...) if err != nil { log.Printf("Error validating subtasks: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error validating subtasks: %v", err), http.StatusInternalServerError) return } defer validSubtaskRows.Close() validSubtaskIDs := make(map[int]bool) for validSubtaskRows.Next() { var id int if err := validSubtaskRows.Scan(&id); err == nil { validSubtaskIDs[id] = true } } // Вставляем только валидные подзадачи for _, subtaskID := range childrenIDs { if validSubtaskIDs[subtaskID] { _, err = tx.Exec(` INSERT INTO task_draft_subtasks (task_draft_id, subtask_id) VALUES ($1, $2) ON CONFLICT (task_draft_id, subtask_id) DO NOTHING `, draftID, subtaskID) if err != nil { log.Printf("Error inserting draft subtask: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error inserting draft subtask: %v", err), http.StatusInternalServerError) return } } } } // Коммитим транзакцию if err = tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Task will be completed at end of day", }) } // completeTaskHandler выполняет задачу func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } var req CompleteTaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding complete task request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Используем executeTask для выполнения задачи err = a.executeTask(taskID, userID, req) if err != nil { if strings.Contains(err.Error(), "not found") { sendErrorWithCORS(w, err.Error(), http.StatusNotFound) } else if strings.Contains(err.Error(), "unlocked") { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) } else if strings.Contains(err.Error(), "required") { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) } else { log.Printf("Error executing task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error executing task: %v", err), http.StatusInternalServerError) } return } // Возвращаем обновлённый список задач чтобы фронтенд не делал повторный GET tasks, err := a.fetchTasksForUser(userID) if err != nil { log.Printf("Error fetching tasks after completion: %v", err) // Фолбэк: возвращаем минимальный ответ, фронтенд сам обновится w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Task completed successfully", }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "tasks": tasks, }) } // completeAndDeleteTaskHandler выполняет задачу и затем удаляет её func (a *App) completeAndDeleteTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } // Сначала выполняем задачу используя executeTask var req CompleteTaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding complete task request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Используем executeTask для выполнения задачи err = a.executeTask(taskID, userID, req) if err != nil { if strings.Contains(err.Error(), "not found") { sendErrorWithCORS(w, err.Error(), http.StatusNotFound) } else if strings.Contains(err.Error(), "unlocked") { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) } else if strings.Contains(err.Error(), "required") { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) } else { log.Printf("Error executing task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error executing task: %v", err), http.StatusInternalServerError) } return } // Помечаем задачу как удаленную _, err = a.DB.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1 AND user_id = $2", taskID, userID) if err != nil { log.Printf("Error deleting task: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting task: %v", err), http.StatusInternalServerError) return } // Возвращаем обновлённый список задач чтобы фронтенд не делал повторный GET tasks, err := a.fetchTasksForUser(userID) if err != nil { log.Printf("Error fetching tasks after completion: %v", err) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Task completed and deleted successfully", }) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "tasks": tasks, }) } // postponeTaskHandler переносит задачу на указанную дату func (a *App) postponeTaskHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) taskID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest) return } var req PostponeTaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding postpone task request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Проверяем владельца var ownerID int err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Task not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking task ownership: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError) return } // Если NextShowAt == nil, устанавливаем next_show_at в NULL // Иначе парсим дату и устанавливаем значение var nextShowAtValue interface{} if req.NextShowAt == nil || *req.NextShowAt == "" { nextShowAtValue = nil } else { nextShowAt, err := time.Parse(time.RFC3339, *req.NextShowAt) if err != nil { log.Printf("Error parsing next_show_at: %v", err) sendErrorWithCORS(w, "Invalid date format. Use RFC3339 format", http.StatusBadRequest) return } nextShowAtValue = nextShowAt } // Обновляем next_show_at _, err = a.DB.Exec(` UPDATE tasks SET next_show_at = $1 WHERE id = $2 AND user_id = $3 `, nextShowAtValue, taskID, userID) if err != nil { log.Printf("Error updating next_show_at: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating next_show_at: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Task postponed successfully", }) } // todoistDisconnectHandler отключает интеграцию Todoist func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } _, err := a.DB.Exec(` DELETE FROM todoist_integrations WHERE user_id = $1 `, userID) if err != nil { log.Printf("Todoist disconnect: DB error: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Failed to disconnect: %v", err), http.StatusInternalServerError) return } log.Printf("Todoist disconnected for user_id=%d", userID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Todoist disconnected", }) } // ============================================ // Fitbit OAuth handlers // ============================================ // generateFitbitOAuthState генерирует JWT state для Fitbit OAuth func generateFitbitOAuthState(userID int, jwtSecret []byte) (string, error) { claims := OAuthStateClaims{ UserID: userID, Type: "fitbit_oauth", RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // 1 день IssuedAt: jwt.NewNumericDate(time.Now()), }, } token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString(jwtSecret) } // validateFitbitOAuthState проверяет и извлекает user_id из JWT state для Fitbit func validateFitbitOAuthState(stateString string, jwtSecret []byte) (int, error) { token, err := jwt.ParseWithClaims(stateString, &OAuthStateClaims{}, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return jwtSecret, nil }) if err != nil { return 0, err } claims, ok := token.Claims.(*OAuthStateClaims) if !ok || !token.Valid { return 0, fmt.Errorf("invalid token") } if claims.Type != "fitbit_oauth" { return 0, fmt.Errorf("wrong token type") } return claims.UserID, nil } // exchangeFitbitCodeForToken обменивает OAuth code на access_token и refresh_token для Fitbit func exchangeFitbitCodeForToken(code, redirectURI, clientID, clientSecret string) (accessToken, refreshToken string, expiresIn int, err error) { data := url.Values{} data.Set("grant_type", "authorization_code") data.Set("code", code) data.Set("redirect_uri", redirectURI) req, err := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode())) if err != nil { return "", "", 0, fmt.Errorf("failed to create request: %w", err) } // Fitbit требует Basic Auth для Server приложений auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) req.Header.Set("Authorization", "Basic "+auth) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return "", "", 0, fmt.Errorf("failed to exchange code: %w", err) } defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", "", 0, fmt.Errorf("token exchange failed (status %d): %s", resp.StatusCode, string(bodyBytes)) } var result struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` UserID string `json:"user_id"` Error string `json:"error"` ErrorDesc string `json:"error_description"` } if err := json.Unmarshal(bodyBytes, &result); err != nil { return "", "", 0, fmt.Errorf("failed to decode response: %w", err) } if result.Error != "" { return "", "", 0, fmt.Errorf("token exchange error: %s - %s", result.Error, result.ErrorDesc) } return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil } // getFitbitUserInfo получает информацию о пользователе Fitbit func getFitbitUserInfo(accessToken string) (string, error) { req, err := http.NewRequest("GET", "https://api.fitbit.com/1/user/-/profile.json", nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("failed to get user info: %w", err) } defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("get user info failed (status %d): %s", resp.StatusCode, string(bodyBytes)) } var result struct { User struct { EncodedID string `json:"encodedId"` } `json:"user"` } if err := json.Unmarshal(bodyBytes, &result); err != nil { return "", fmt.Errorf("failed to decode response: %w", err) } if result.User.EncodedID == "" { return "", fmt.Errorf("user ID not found in response") } return result.User.EncodedID, nil } // refreshFitbitToken обновляет access_token используя refresh_token func refreshFitbitToken(refreshToken, clientID, clientSecret string) (accessToken, newRefreshToken string, expiresIn int, err error) { data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) req, err := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode())) if err != nil { return "", "", 0, fmt.Errorf("failed to create request: %w", err) } auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret)) req.Header.Set("Authorization", "Basic "+auth) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return "", "", 0, fmt.Errorf("failed to refresh token: %w", err) } defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", "", 0, fmt.Errorf("token refresh failed (status %d): %s", resp.StatusCode, string(bodyBytes)) } var result struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` Error string `json:"error"` ErrorDesc string `json:"error_description"` } if err := json.Unmarshal(bodyBytes, &result); err != nil { return "", "", 0, fmt.Errorf("failed to decode response: %w", err) } if result.Error != "" { return "", "", 0, fmt.Errorf("token refresh error: %s - %s", result.Error, result.ErrorDesc) } return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil } // fitbitOAuthConnectHandler инициирует OAuth flow для Fitbit func (a *App) fitbitOAuthConnectHandler(w http.ResponseWriter, r *http.Request) { setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } clientID := getEnv("FITBIT_CLIENT_ID", "") clientSecret := getEnv("FITBIT_CLIENT_SECRET", "") baseURL := getEnv("WEBHOOK_BASE_URL", "") if clientID == "" || clientSecret == "" { sendErrorWithCORS(w, "FITBIT_CLIENT_ID and FITBIT_CLIENT_SECRET must be configured", http.StatusInternalServerError) return } if baseURL == "" { sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError) return } redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/fitbit/oauth/callback" state, err := generateFitbitOAuthState(userID, a.jwtSecret) if err != nil { log.Printf("Fitbit OAuth: failed to generate state: %v", err) sendErrorWithCORS(w, "Failed to generate OAuth state", http.StatusInternalServerError) return } // Fitbit OAuth URL с необходимыми scopes authURL := fmt.Sprintf( "https://www.fitbit.com/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=activity%%20profile&state=%s", url.QueryEscape(clientID), url.QueryEscape(redirectURI), url.QueryEscape(state), ) log.Printf("Fitbit OAuth: returning auth URL for user_id=%d", userID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "auth_url": authURL, }) } // fitbitOAuthCallbackHandler обрабатывает OAuth callback от Fitbit func (a *App) fitbitOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Fitbit OAuth callback: received request, URL=%s", r.URL.String()) frontendURL := getEnv("WEBHOOK_BASE_URL", "") redirectSuccess := frontendURL + "/?integration=fitbit&status=connected" redirectError := frontendURL + "/?integration=fitbit&status=error" clientID := getEnv("FITBIT_CLIENT_ID", "") clientSecret := getEnv("FITBIT_CLIENT_SECRET", "") baseURL := getEnv("WEBHOOK_BASE_URL", "") log.Printf("Fitbit OAuth callback: WEBHOOK_BASE_URL=%s, FITBIT_CLIENT_ID set=%v, FITBIT_CLIENT_SECRET set=%v", baseURL, clientID != "", clientSecret != "") if clientID == "" || clientSecret == "" || baseURL == "" { log.Printf("Fitbit OAuth: missing configuration (clientID=%v, clientSecret=%v, baseURL=%v)", clientID != "", clientSecret != "", baseURL != "") http.Redirect(w, r, redirectError+"&message=config_error", http.StatusTemporaryRedirect) return } redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/fitbit/oauth/callback" log.Printf("Fitbit OAuth callback: redirectURI=%s", redirectURI) // Проверяем state state := r.URL.Query().Get("state") userID, err := validateFitbitOAuthState(state, a.jwtSecret) if err != nil { log.Printf("Fitbit OAuth: invalid state: %v (state length=%d)", err, len(state)) http.Redirect(w, r, redirectError+"&message=invalid_state", http.StatusTemporaryRedirect) return } log.Printf("Fitbit OAuth callback: validated state, user_id=%d", userID) // Получаем code code := r.URL.Query().Get("code") if code == "" { // Проверяем наличие ошибки от Fitbit fitbitError := r.URL.Query().Get("error") fitbitErrorDesc := r.URL.Query().Get("error_description") log.Printf("Fitbit OAuth: no code in callback, error=%s, error_description=%s", fitbitError, fitbitErrorDesc) http.Redirect(w, r, redirectError+"&message=no_code", http.StatusTemporaryRedirect) return } log.Printf("Fitbit OAuth callback: got code, exchanging for tokens...") // Обмениваем code на токены accessToken, refreshToken, expiresIn, err := exchangeFitbitCodeForToken(code, redirectURI, clientID, clientSecret) if err != nil { log.Printf("Fitbit OAuth: token exchange failed for user_id=%d: %v", userID, err) http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect) return } log.Printf("Fitbit OAuth callback: token exchange successful, expiresIn=%d", expiresIn) // Получаем информацию о пользователе fitbitUserID, err := getFitbitUserInfo(accessToken) if err != nil { log.Printf("Fitbit OAuth: get user info failed for user_id=%d: %v", userID, err) http.Redirect(w, r, redirectError+"&message=user_info_failed", http.StatusTemporaryRedirect) return } log.Printf("Fitbit OAuth: user_id=%d connected fitbit_user_id=%s", userID, fitbitUserID) // Вычисляем время истечения токена tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second) // Сохраняем в БД _, err = a.DB.Exec(` INSERT INTO fitbit_integrations (user_id, fitbit_user_id, access_token, refresh_token, token_expires_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id) DO UPDATE SET fitbit_user_id = $2, access_token = $3, refresh_token = $4, token_expires_at = $5, updated_at = CURRENT_TIMESTAMP `, userID, fitbitUserID, accessToken, refreshToken, tokenExpiresAt) if err != nil { log.Printf("Fitbit OAuth: DB error for user_id=%d: %v", userID, err) http.Redirect(w, r, redirectError+"&message=db_error", http.StatusTemporaryRedirect) return } log.Printf("Fitbit OAuth: successfully saved integration for user_id=%d, redirecting to %s", userID, redirectSuccess) // Редирект на страницу интеграций http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect) } // getFitbitStatusHandler возвращает статус подключения Fitbit func (a *App) getFitbitStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var fitbitUserID sql.NullString var stepsTaskID, floorsTaskID sql.NullInt64 var stepsGoalTaskID, stepsGoalSubtaskID sql.NullInt64 var floorsGoalTaskID, floorsGoalSubtaskID sql.NullInt64 err := a.DB.QueryRow(` SELECT fitbit_user_id, steps_task_id, floors_task_id, steps_goal_task_id, steps_goal_subtask_id, floors_goal_task_id, floors_goal_subtask_id FROM fitbit_integrations WHERE user_id = $1 AND access_token IS NOT NULL `, userID).Scan( &fitbitUserID, &stepsTaskID, &floorsTaskID, &stepsGoalTaskID, &stepsGoalSubtaskID, &floorsGoalTaskID, &floorsGoalSubtaskID, ) if err == sql.ErrNoRows || !fitbitUserID.Valid { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "connected": false, }) return } if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to get status: %v", err), http.StatusInternalServerError) return } var stepsTaskIDValue, floorsTaskIDValue interface{} var stepsGoalTaskIDValue, stepsGoalSubtaskIDValue interface{} var floorsGoalTaskIDValue, floorsGoalSubtaskIDValue interface{} if stepsTaskID.Valid { stepsTaskIDValue = stepsTaskID.Int64 } if floorsTaskID.Valid { floorsTaskIDValue = floorsTaskID.Int64 } if stepsGoalTaskID.Valid { stepsGoalTaskIDValue = stepsGoalTaskID.Int64 } if stepsGoalSubtaskID.Valid { stepsGoalSubtaskIDValue = stepsGoalSubtaskID.Int64 } if floorsGoalTaskID.Valid { floorsGoalTaskIDValue = floorsGoalTaskID.Int64 } if floorsGoalSubtaskID.Valid { floorsGoalSubtaskIDValue = floorsGoalSubtaskID.Int64 } response := map[string]interface{}{ "connected": true, "bindings": map[string]interface{}{ "steps_task_id": stepsTaskIDValue, "floors_task_id": floorsTaskIDValue, "steps_goal_task_id": stepsGoalTaskIDValue, "steps_goal_subtask_id": stepsGoalSubtaskIDValue, "floors_goal_task_id": floorsGoalTaskIDValue, "floors_goal_subtask_id": floorsGoalSubtaskIDValue, }, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // fitbitDisconnectHandler отключает интеграцию Fitbit func (a *App) fitbitDisconnectHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } _, err := a.DB.Exec(` DELETE FROM fitbit_integrations WHERE user_id = $1 `, userID) if err != nil { log.Printf("Fitbit disconnect: DB error: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Failed to disconnect: %v", err), http.StatusInternalServerError) return } log.Printf("Fitbit disconnected for user_id=%d", userID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Fitbit disconnected", }) } // updateFitbitBindingsHandler обновляет привязки задач для Fitbit func (a *App) updateFitbitBindingsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req struct { StepsTaskID *int `json:"steps_task_id"` FloorsTaskID *int `json:"floors_task_id"` StepsGoalTaskID *int `json:"steps_goal_task_id"` StepsGoalSubtaskID *int `json:"steps_goal_subtask_id"` FloorsGoalTaskID *int `json:"floors_goal_task_id"` FloorsGoalSubtaskID *int `json:"floors_goal_subtask_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } validateTask := func(taskID *int, fieldName string) error { if taskID == nil { return nil } var exists bool err := a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE) `, *taskID, userID).Scan(&exists) if err != nil || !exists { return fmt.Errorf("%s: task %d not found", fieldName, *taskID) } return nil } validateSubtask := func(subtaskID *int, parentTaskID *int, fieldName string) error { if subtaskID == nil { return nil } if parentTaskID == nil { return fmt.Errorf("%s: parent task is required", fieldName) } var exists bool err := a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND parent_task_id = $2 AND user_id = $3 AND deleted = FALSE) `, *subtaskID, *parentTaskID, userID).Scan(&exists) if err != nil || !exists { return fmt.Errorf("%s: subtask %d is not a child of task %d", fieldName, *subtaskID, *parentTaskID) } return nil } if err := validateTask(req.StepsTaskID, "steps_task_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if err := validateTask(req.FloorsTaskID, "floors_task_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if err := validateTask(req.StepsGoalTaskID, "steps_goal_task_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if err := validateTask(req.FloorsGoalTaskID, "floors_goal_task_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if err := validateSubtask(req.StepsGoalSubtaskID, req.StepsGoalTaskID, "steps_goal_subtask_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if err := validateSubtask(req.FloorsGoalSubtaskID, req.FloorsGoalTaskID, "floors_goal_subtask_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } toNullInt64 := func(v *int) sql.NullInt64 { if v == nil { return sql.NullInt64{Valid: false} } return sql.NullInt64{Int64: int64(*v), Valid: true} } _, err := a.DB.Exec(` UPDATE fitbit_integrations SET steps_task_id = $1, floors_task_id = $2, steps_goal_task_id = $3, steps_goal_subtask_id = $4, floors_goal_task_id = $5, floors_goal_subtask_id = $6, updated_at = CURRENT_TIMESTAMP WHERE user_id = $7 `, toNullInt64(req.StepsTaskID), toNullInt64(req.FloorsTaskID), toNullInt64(req.StepsGoalTaskID), toNullInt64(req.StepsGoalSubtaskID), toNullInt64(req.FloorsGoalTaskID), toNullInt64(req.FloorsGoalSubtaskID), userID, ) if err != nil { log.Printf("Fitbit update bindings: DB error: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Failed to update bindings: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Bindings updated", }) } // updateFitbitStepsBindingsHandler обновляет только привязки для шагов func (a *App) updateFitbitStepsBindingsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req struct { StepsTaskID *int `json:"steps_task_id"` StepsGoalTaskID *int `json:"steps_goal_task_id"` StepsGoalSubtaskID *int `json:"steps_goal_subtask_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } validateTask := func(taskID *int, fieldName string) error { if taskID == nil { return nil } var exists bool err := a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE) `, *taskID, userID).Scan(&exists) if err != nil || !exists { return fmt.Errorf("%s: task not found", fieldName) } return nil } validateSubtask := func(subtaskID *int, parentTaskID *int, fieldName string) error { if subtaskID == nil { return nil } if parentTaskID == nil { return fmt.Errorf("%s: parent task is required", fieldName) } var exists bool err := a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND parent_task_id = $2 AND user_id = $3 AND deleted = FALSE) `, *subtaskID, *parentTaskID, userID).Scan(&exists) if err != nil || !exists { return fmt.Errorf("%s: subtask not found", fieldName) } return nil } if err := validateTask(req.StepsTaskID, "steps_task_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if err := validateTask(req.StepsGoalTaskID, "steps_goal_task_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if err := validateSubtask(req.StepsGoalSubtaskID, req.StepsGoalTaskID, "steps_goal_subtask_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } toNullInt64 := func(v *int) sql.NullInt64 { if v == nil { return sql.NullInt64{Valid: false} } return sql.NullInt64{Int64: int64(*v), Valid: true} } _, err := a.DB.Exec(` UPDATE fitbit_integrations SET steps_task_id = $1, steps_goal_task_id = $2, steps_goal_subtask_id = $3, updated_at = CURRENT_TIMESTAMP WHERE user_id = $4 `, toNullInt64(req.StepsTaskID), toNullInt64(req.StepsGoalTaskID), toNullInt64(req.StepsGoalSubtaskID), userID) if err != nil { log.Printf("Fitbit update steps bindings: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Failed to update bindings: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "message": "Steps bindings updated"}) } // updateFitbitFloorsBindingsHandler обновляет только привязки для этажей func (a *App) updateFitbitFloorsBindingsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req struct { FloorsTaskID *int `json:"floors_task_id"` FloorsGoalTaskID *int `json:"floors_goal_task_id"` FloorsGoalSubtaskID *int `json:"floors_goal_subtask_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } validateTask := func(taskID *int, fieldName string) error { if taskID == nil { return nil } var exists bool err := a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE) `, *taskID, userID).Scan(&exists) if err != nil || !exists { return fmt.Errorf("%s: task not found", fieldName) } return nil } validateSubtask := func(subtaskID *int, parentTaskID *int, fieldName string) error { if subtaskID == nil { return nil } if parentTaskID == nil { return fmt.Errorf("%s: parent task is required", fieldName) } var exists bool err := a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND parent_task_id = $2 AND user_id = $3 AND deleted = FALSE) `, *subtaskID, *parentTaskID, userID).Scan(&exists) if err != nil || !exists { return fmt.Errorf("%s: subtask not found", fieldName) } return nil } if err := validateTask(req.FloorsTaskID, "floors_task_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if err := validateTask(req.FloorsGoalTaskID, "floors_goal_task_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } if err := validateSubtask(req.FloorsGoalSubtaskID, req.FloorsGoalTaskID, "floors_goal_subtask_id"); err != nil { sendErrorWithCORS(w, err.Error(), http.StatusBadRequest) return } toNullInt64 := func(v *int) sql.NullInt64 { if v == nil { return sql.NullInt64{Valid: false} } return sql.NullInt64{Int64: int64(*v), Valid: true} } _, err := a.DB.Exec(` UPDATE fitbit_integrations SET floors_task_id = $1, floors_goal_task_id = $2, floors_goal_subtask_id = $3, updated_at = CURRENT_TIMESTAMP WHERE user_id = $4 `, toNullInt64(req.FloorsTaskID), toNullInt64(req.FloorsGoalTaskID), toNullInt64(req.FloorsGoalSubtaskID), userID) if err != nil { log.Printf("Fitbit update floors bindings: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Failed to update bindings: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "message": "Floors bindings updated"}) } // getFitbitAccessToken получает актуальный access_token (обновляет если нужно) func (a *App) getFitbitAccessToken(userID int) (string, error) { var accessToken, refreshToken sql.NullString var tokenExpiresAt sql.NullTime err := a.DB.QueryRow(` SELECT access_token, refresh_token, token_expires_at FROM fitbit_integrations WHERE user_id = $1 `, userID).Scan(&accessToken, &refreshToken, &tokenExpiresAt) if err == sql.ErrNoRows { return "", fmt.Errorf("fitbit integration not found") } if err != nil { return "", fmt.Errorf("failed to get tokens: %w", err) } if !accessToken.Valid { return "", fmt.Errorf("access token not found") } // Проверяем, не истек ли токен (с запасом 5 минут) if tokenExpiresAt.Valid && time.Now().Add(5*time.Minute).After(tokenExpiresAt.Time) { // Токен истек или скоро истечет, обновляем if !refreshToken.Valid { return "", fmt.Errorf("refresh token not found") } clientID := getEnv("FITBIT_CLIENT_ID", "") clientSecret := getEnv("FITBIT_CLIENT_SECRET", "") if clientID == "" || clientSecret == "" { return "", fmt.Errorf("FITBIT_CLIENT_ID and FITBIT_CLIENT_SECRET must be configured") } newAccessToken, newRefreshToken, expiresIn, err := refreshFitbitToken(refreshToken.String, clientID, clientSecret) if err != nil { return "", fmt.Errorf("failed to refresh token: %w", err) } // Обновляем токены в БД tokenExpiresAtNew := time.Now().Add(time.Duration(expiresIn) * time.Second) _, err = a.DB.Exec(` UPDATE fitbit_integrations SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = CURRENT_TIMESTAMP WHERE user_id = $4 `, newAccessToken, newRefreshToken, tokenExpiresAtNew, userID) if err != nil { return "", fmt.Errorf("failed to update tokens: %w", err) } log.Printf("Fitbit token refreshed for user_id=%d", userID) return newAccessToken, nil } return accessToken.String, nil } // syncFitbitData синхронизирует данные из Fitbit API для указанной даты func (a *App) syncFitbitData(userID int, date time.Time) error { accessToken, err := a.getFitbitAccessToken(userID) if err != nil { return fmt.Errorf("failed to get access token: %w", err) } dateStr := date.Format("2006-01-02") // Получаем данные активности за день activityURL := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/date/%s.json", dateStr) req, err := http.NewRequest("GET", activityURL, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to get activity data: %w", err) } defer resp.Body.Close() bodyBytes, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return fmt.Errorf("get activity data failed (status %d): %s", resp.StatusCode, string(bodyBytes)) } var activityData struct { Summary struct { Steps int `json:"steps"` Floors int `json:"floors"` } `json:"summary"` } if err := json.Unmarshal(bodyBytes, &activityData); err != nil { return fmt.Errorf("failed to decode activity data: %w", err) } // Получаем цели пользователя из Fitbit API goalsURL := "https://api.fitbit.com/1/user/-/activities/goals/daily.json" reqGoals, err := http.NewRequest("GET", goalsURL, nil) if err != nil { return fmt.Errorf("failed to create goals request: %w", err) } reqGoals.Header.Set("Authorization", "Bearer "+accessToken) reqGoals.Header.Set("Accept", "application/json") respGoals, err := client.Do(reqGoals) if err != nil { return fmt.Errorf("failed to get goals data: %w", err) } defer respGoals.Body.Close() var goalSteps, goalFloors int if respGoals.StatusCode == http.StatusOK { bodyBytesGoals, _ := io.ReadAll(respGoals.Body) var goalsData struct { Goals struct { Steps int `json:"steps"` Floors int `json:"floors"` } `json:"goals"` } if err := json.Unmarshal(bodyBytesGoals, &goalsData); err == nil { goalSteps = goalsData.Goals.Steps goalFloors = goalsData.Goals.Floors } } if goalSteps == 0 { goalSteps = 10000 } if goalFloors == 0 { goalFloors = 10 } // Сохраняем данные в БД _, err = a.DB.Exec(` INSERT INTO fitbit_daily_stats (user_id, date, steps, floors, goal_steps, goal_floors, updated_at) VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP) ON CONFLICT (user_id, date) DO UPDATE SET steps = $3, floors = $4, goal_steps = $5, goal_floors = $6, updated_at = CURRENT_TIMESTAMP `, userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, goalSteps, goalFloors) if err != nil { return fmt.Errorf("failed to save stats: %w", err) } // Получаем привязки задач из fitbit_integrations var stepsTaskID, floorsTaskID sql.NullInt64 var stepsGoalTaskID, stepsGoalSubtaskID sql.NullInt64 var floorsGoalTaskID, floorsGoalSubtaskID sql.NullInt64 err = a.DB.QueryRow(` SELECT steps_task_id, floors_task_id, steps_goal_task_id, steps_goal_subtask_id, floors_goal_task_id, floors_goal_subtask_id FROM fitbit_integrations WHERE user_id = $1 `, userID).Scan( &stepsTaskID, &floorsTaskID, &stepsGoalTaskID, &stepsGoalSubtaskID, &floorsGoalTaskID, &floorsGoalSubtaskID, ) if err != nil && err != sql.ErrNoRows { log.Printf("Error getting fitbit bindings: %v", err) } steps := activityData.Summary.Steps floors := activityData.Summary.Floors if stepsTaskID.Valid { if err := a.saveFitbitProgressDraft(userID, int(stepsTaskID.Int64), float64(steps)); err != nil { log.Printf("Error saving steps draft: %v", err) } } if floorsTaskID.Valid { if err := a.saveFitbitProgressDraft(userID, int(floorsTaskID.Int64), float64(floors)); err != nil { log.Printf("Error saving floors draft: %v", err) } } if stepsGoalTaskID.Valid && stepsGoalSubtaskID.Valid { goalReached := steps >= goalSteps if err := a.saveFitbitSubtaskDraft(userID, int(stepsGoalTaskID.Int64), int(stepsGoalSubtaskID.Int64), goalReached); err != nil { log.Printf("Error saving steps goal subtask draft: %v", err) } } else if stepsGoalTaskID.Valid { goalReached := steps >= goalSteps if err := a.setFitbitTaskDraftAutoComplete(userID, int(stepsGoalTaskID.Int64), goalReached); err != nil { log.Printf("Error setting steps goal task draft auto_complete: %v", err) } } if floorsGoalTaskID.Valid && floorsGoalSubtaskID.Valid { goalReached := floors >= goalFloors if err := a.saveFitbitSubtaskDraft(userID, int(floorsGoalTaskID.Int64), int(floorsGoalSubtaskID.Int64), goalReached); err != nil { log.Printf("Error saving floors goal subtask draft: %v", err) } } else if floorsGoalTaskID.Valid { goalReached := floors >= goalFloors if err := a.setFitbitTaskDraftAutoComplete(userID, int(floorsGoalTaskID.Int64), goalReached); err != nil { log.Printf("Error setting floors goal task draft auto_complete: %v", err) } } log.Printf("Fitbit data synced for user_id=%d, date=%s: steps=%d, floors=%d, goalSteps=%d, goalFloors=%d", userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, goalSteps, goalFloors) return nil } // saveFitbitProgressDraft сохраняет драфт задачи с прогрессом // Устанавливает progression_value и auto_complete = true func (a *App) saveFitbitProgressDraft(userID int, taskID int, progressionValue float64) error { var exists bool err := a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE) `, taskID, userID).Scan(&exists) if err != nil || !exists { return fmt.Errorf("task %d not found or not owned by user", taskID) } var draftID int err = a.DB.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID) if err == sql.ErrNoRows { _, err = a.DB.Exec(` INSERT INTO task_drafts (task_id, user_id, progression_value, auto_complete, created_at, updated_at) VALUES ($1, $2, $3, TRUE, NOW(), NOW()) `, taskID, userID, progressionValue) } else if err == nil { _, err = a.DB.Exec(` UPDATE task_drafts SET progression_value = $1, auto_complete = TRUE, updated_at = NOW() WHERE id = $2 `, progressionValue, draftID) } if err != nil { return fmt.Errorf("failed to save draft: %w", err) } log.Printf("Fitbit: saved progress draft for task_id=%d, value=%.2f", taskID, progressionValue) return nil } // saveFitbitSubtaskDraft сохраняет драфт с checked/unchecked подзадачей // taskID - родительская задача (для драфта) // subtaskID - подзадача которую нужно отметить // checked - true если цель достигнута, false если нет func (a *App) saveFitbitSubtaskDraft(userID int, taskID int, subtaskID int, checked bool) error { var exists bool err := a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE) `, taskID, userID).Scan(&exists) if err != nil || !exists { return fmt.Errorf("task %d not found or not owned by user", taskID) } err = a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND parent_task_id = $2 AND deleted = FALSE) `, subtaskID, taskID).Scan(&exists) if err != nil || !exists { return fmt.Errorf("subtask %d is not a child of task %d", subtaskID, taskID) } tx, err := a.DB.Begin() if err != nil { return fmt.Errorf("failed to begin transaction: %w", err) } defer tx.Rollback() var draftID int err = tx.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID) if err == sql.ErrNoRows { err = tx.QueryRow(` INSERT INTO task_drafts (task_id, user_id, auto_complete, created_at, updated_at) VALUES ($1, $2, TRUE, NOW(), NOW()) RETURNING id `, taskID, userID).Scan(&draftID) if err != nil { return fmt.Errorf("failed to create draft: %w", err) } } else if err != nil { return fmt.Errorf("failed to check draft: %w", err) } else { _, err = tx.Exec(` UPDATE task_drafts SET auto_complete = TRUE, updated_at = NOW() WHERE id = $1 `, draftID) if err != nil { return fmt.Errorf("failed to update draft: %w", err) } } if checked { _, err = tx.Exec(` INSERT INTO task_draft_subtasks (task_draft_id, subtask_id) VALUES ($1, $2) ON CONFLICT (task_draft_id, subtask_id) DO NOTHING `, draftID, subtaskID) } else { _, err = tx.Exec(` DELETE FROM task_draft_subtasks WHERE task_draft_id = $1 AND subtask_id = $2 `, draftID, subtaskID) } if err != nil { return fmt.Errorf("failed to update subtask: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("failed to commit: %w", err) } log.Printf("Fitbit: saved subtask draft for task_id=%d, subtask_id=%d, checked=%v", taskID, subtaskID, checked) return nil } // setFitbitTaskDraftAutoComplete создаёт или обновляет драфт задачи, выставляя только флаг «Выполнить в конце дня». // Используется для достижения цели по шагам/этажам, когда выбрана задача без подзадачи. func (a *App) setFitbitTaskDraftAutoComplete(userID int, taskID int, autoComplete bool) error { var exists bool err := a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE) `, taskID, userID).Scan(&exists) if err != nil || !exists { return fmt.Errorf("task %d not found or not owned by user", taskID) } var draftID int err = a.DB.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID) if err == sql.ErrNoRows { if !autoComplete { return nil } _, err = a.DB.Exec(` INSERT INTO task_drafts (task_id, user_id, auto_complete, created_at, updated_at) VALUES ($1, $2, TRUE, NOW(), NOW()) `, taskID, userID) if err != nil { return fmt.Errorf("failed to create draft: %w", err) } log.Printf("Fitbit: created task draft for task_id=%d, auto_complete=true", taskID) return nil } if err != nil { return fmt.Errorf("failed to get draft: %w", err) } _, err = a.DB.Exec(` UPDATE task_drafts SET auto_complete = $1, updated_at = NOW() WHERE id = $2 `, autoComplete, draftID) if err != nil { return fmt.Errorf("failed to update draft: %w", err) } if !autoComplete { _, _ = a.DB.Exec("DELETE FROM task_draft_subtasks WHERE task_draft_id = $1", draftID) } log.Printf("Fitbit: set task draft auto_complete for task_id=%d to %v", taskID, autoComplete) return nil } // fitbitSyncHandler выполняет ручную синхронизацию данных Fitbit func (a *App) fitbitSyncHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Синхронизируем данные за сегодня (в настроенном часовом поясе) timezoneStr := getEnv("TIMEZONE", "UTC") loc, locErr := time.LoadLocation(timezoneStr) if locErr != nil { log.Printf("Warning: Invalid timezone '%s': %v. Using UTC for Fitbit sync.", timezoneStr, locErr) loc = time.UTC } err := a.syncFitbitData(userID, time.Now().In(loc)) if err != nil { log.Printf("Fitbit sync error: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Sync failed: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Data synced successfully", }) } // getFitbitStatsHandler возвращает статистику Fitbit за указанную дату func (a *App) getFitbitStatsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Получаем дату из query параметра (по умолчанию сегодня в настроенном часовом поясе) dateStr := r.URL.Query().Get("date") if dateStr == "" { timezoneStr := getEnv("TIMEZONE", "UTC") loc, locErr := time.LoadLocation(timezoneStr) if locErr != nil { loc = time.UTC } dateStr = time.Now().In(loc).Format("2006-01-02") } var steps, floors, goalSteps, goalFloors sql.NullInt64 err := a.DB.QueryRow(` SELECT steps, floors, goal_steps, goal_floors FROM fitbit_daily_stats WHERE user_id = $1 AND date = $2 `, userID, dateStr).Scan(&steps, &floors, &goalSteps, &goalFloors) if err == sql.ErrNoRows { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "date": dateStr, "steps": map[string]interface{}{ "value": 0, "goal": 10000, }, "floors": map[string]interface{}{ "value": 0, "goal": 10, }, }) return } if err != nil { sendErrorWithCORS(w, fmt.Sprintf("Failed to get stats: %v", err), http.StatusInternalServerError) return } goalStepsValue := goalSteps.Int64 if !goalSteps.Valid || goalStepsValue == 0 { goalStepsValue = 10000 } goalFloorsValue := goalFloors.Int64 if !goalFloors.Valid || goalFloorsValue == 0 { goalFloorsValue = 10 } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "date": dateStr, "steps": map[string]interface{}{ "value": steps.Int64, "goal": goalStepsValue, }, "floors": map[string]interface{}{ "value": floors.Int64, "goal": goalFloorsValue, }, }) } // ============================================ // Wishlist handlers // ============================================ // calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента // Считает напрямую из таблицы nodes, используя денормализованное поле created_date func (a *App) calculateProjectPointsFromDate( projectID int, startDate sql.NullTime, userID int, ) (float64, error) { var totalScore float64 var err error if !startDate.Valid { // За всё время - считаем все nodes этого пользователя для указанного проекта err = a.DB.QueryRow(` SELECT COALESCE(SUM(n.score), 0) FROM nodes n JOIN projects p ON n.project_id = p.id WHERE n.project_id = $1 AND n.user_id = $2 AND p.user_id = $2 `, projectID, userID).Scan(&totalScore) } else { // С указанной даты до текущего момента // Считаем все nodes этого пользователя, где дата created_date >= startDate // Используем DATE() для сравнения только по дате (без времени) // Теперь используем nodes.created_date напрямую (без JOIN с entries) err = a.DB.QueryRow(` SELECT COALESCE(SUM(n.score), 0) FROM nodes n 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(n.created_date) >= DATE($3) `, projectID, userID, startDate.Time).Scan(&totalScore) } if err != nil { log.Printf("Error calculating project points from date: %v", err) return 0, err } return totalScore, nil } // getProjectMinGoalScoreCurrentWeek получает min_goal_score проекта для текущей недели из weekly_goals. // Используется как «недельная норма» баллов при расчёте срока разблокировки желаний. // Если запись отсутствует или значение 0, возвращает ошибку. func (a *App) getProjectMinGoalScoreCurrentWeek(projectID int) (float64, error) { var minGoal float64 err := a.DB.QueryRow(` SELECT min_goal_score FROM weekly_goals WHERE project_id = $1 AND goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER `, projectID).Scan(&minGoal) if err != nil { if err == sql.ErrNoRows { return 0, fmt.Errorf("min_goal_score not found for project %d (current week)", projectID) } return 0, err } return minGoal, nil } // calculateProjectUnlockWeeks рассчитывает срок разблокировки проекта в неделях // projectID - ID проекта // requiredPoints - необходимое количество баллов // startDate - дата начала подсчета (может быть nil - за всё время) // userID - ID пользователя (владельца условия) // «Недельная норма» берётся из weekly_goals.min_goal_score для текущей недели. // Возвращает количество недель (float64): // - > 0: условие не выполнено, возвращает количество недель // - 0: условие уже выполнено (remaining <= 0) // - 99999: min_goal_score отсутствует или равен 0 (нельзя рассчитать) или ошибка расчета func (a *App) calculateProjectUnlockWeeks(projectID int, requiredPoints float64, startDate sql.NullTime, userID int) float64 { // 1. Получаем текущие баллы от startDate currentPoints, err := a.calculateProjectPointsFromDate(projectID, startDate, userID) if err != nil { log.Printf("Error calculating project points for project %d, user %d: %v", projectID, userID, err) return 99999 // Ошибка расчета - возвращаем 99999 } // 2. Вычисляем остаток remaining := requiredPoints - currentPoints if remaining <= 0 { // Условие уже выполнено return 0 } // 3. Получаем недельную норму (min_goal_score текущей недели) minGoal, err := a.getProjectMinGoalScoreCurrentWeek(projectID) if err != nil || minGoal <= 0 { // Если min_goal_score отсутствует или равен 0, возвращаем 99999 (нельзя рассчитать) // Это нормальная ситуация, не логируем return 99999 } // 4. Рассчитываем недели weeks := remaining / minGoal return weeks } // formatWeeksText форматирует количество недель в текстовый формат // weeks - количество недель (float64) // Возвращает строку: "2 недели", "<1 недели", "5 недель", "∞ недель" и т.д. func formatWeeksText(weeks float64) string { // Если weeks == 0, условие уже выполнено - не показываем срок if weeks == 0 { return "" } // Если weeks >= 99999, это означает что медиана отсутствует или нельзя рассчитать if weeks >= 99999 { return "∞ недель" } if weeks < 0 { return "" } if weeks < 1 { return "<1 недели" } weeksRounded := math.Round(weeks) weeksInt := int(weeksRounded) // Правильное склонение для русского языка var weekWord string lastDigit := weeksInt % 10 lastTwoDigits := weeksInt % 100 if lastTwoDigits >= 11 && lastTwoDigits <= 14 { weekWord = "недель" } else if lastDigit == 1 { weekWord = "неделя" } else if lastDigit >= 2 && lastDigit <= 4 { weekWord = "недели" } else { weekWord = "недель" } return fmt.Sprintf("%d %s", weeksInt, weekWord) } // checkWishlistUnlock проверяет ВСЕ условия для желания // Все условия должны выполняться (AND логика) func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) { // Получаем все условия разблокировки rows, err := a.DB.Query(` SELECT wc.id, wc.display_order, wc.task_condition_id, wc.score_condition_id, wc.user_id AS condition_user_id, tc.task_id, sc.project_id, sc.required_points, sc.start_date FROM wishlist_conditions wc LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id WHERE wc.wishlist_item_id = $1 ORDER BY wc.display_order, wc.id `, itemID) if err != nil { return false, err } defer rows.Close() var hasConditions bool var allConditionsMet = true for rows.Next() { hasConditions = true var wcID, displayOrder int var taskConditionID, scoreConditionID sql.NullInt64 var conditionUserID sql.NullInt64 var taskID sql.NullInt64 var projectID sql.NullInt64 var requiredPoints sql.NullFloat64 var startDate sql.NullTime err := rows.Scan( &wcID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &projectID, &requiredPoints, &startDate, ) if err != nil { return false, err } // Используем user_id из условия, если он есть, иначе используем текущего пользователя conditionOwnerID := userID if conditionUserID.Valid { conditionOwnerID = int(conditionUserID.Int64) } var conditionMet bool if taskConditionID.Valid { // Проверяем условие по задаче if !taskID.Valid { return false, fmt.Errorf("task_id is missing for task_condition_id=%d", taskConditionID.Int64) } var completed int err := a.DB.QueryRow(` SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE `, taskID.Int64, conditionOwnerID).Scan(&completed) if err == sql.ErrNoRows { // Задача удалена или не существует - не блокируем желание conditionMet = true } else if err != nil { return false, err } else { // Задача существует и не удалена — условие не выполнено conditionMet = false } } else if scoreConditionID.Valid { // Проверяем условие по баллам if !projectID.Valid || !requiredPoints.Valid { return false, fmt.Errorf("project_id or required_points missing for score_condition_id=%d", scoreConditionID.Int64) } totalScore, err := a.calculateProjectPointsFromDate( int(projectID.Int64), startDate, conditionOwnerID, ) if err != nil { return false, err } conditionMet = totalScore >= requiredPoints.Float64 } else { return false, fmt.Errorf("invalid condition: neither task nor score condition") } if !conditionMet { allConditionsMet = false } } // Если нет условий - желание разблокировано по умолчанию if !hasConditions { return true, nil } return allConditionsMet, nil } // isConditionLocked определяет, заблокировано ли условие func isConditionLocked(cond UnlockConditionDisplay) bool { if cond.Type == "task_completion" { return cond.TaskCompleted == nil || !*cond.TaskCompleted } else if cond.Type == "project_points" { return cond.CurrentPoints == nil || cond.RequiredPoints == nil || *cond.CurrentPoints < *cond.RequiredPoints } return false } // filterWishesForWeekProgress фильтрует желания для экрана прогресса недели // Возвращает желания со сроком <=1 неделя или готовые (unlocked с перебором <=1 день) // Для каждого проекта оставляет только одно готовое желание с минимальным перебором func (a *App) filterWishesForWeekProgress(wishes []WishlistItem, projectMinGoalScores map[int]float64) []WishlistItem { // Группируем желания по проектам // Для каждого проекта собираем: обычные (срок <=1 неделя) и готовые (unlocked с перебором <=1 день) type wishWithOverflow struct { wish WishlistItem overflow float64 isReady bool } projectWishes := make(map[int][]wishWithOverflow) log.Printf("filterWishesForWeekProgress: total wishes=%d, projectMinGoalScores=%v", len(wishes), projectMinGoalScores) for _, wish := range wishes { if wish.Completed { continue } // Получаем условие по баллам var condition *UnlockConditionDisplay var projectID int if wish.Unlocked { // Для разблокированных желаний ищем условие в unlock_conditions for i := range wish.UnlockConditions { if wish.UnlockConditions[i].Type == "project_points" && wish.UnlockConditions[i].ProjectID != nil { condition = &wish.UnlockConditions[i] projectID = *condition.ProjectID break } } } else { // Для заблокированных желаний берём first_locked_condition if wish.FirstLockedCondition != nil && wish.FirstLockedCondition.ProjectID != nil { condition = wish.FirstLockedCondition projectID = *condition.ProjectID } else if wish.LockedConditionsCount == 0 { // Если все условия выполнены но желание ещё не unlocked, ищем в unlock_conditions for i := range wish.UnlockConditions { if wish.UnlockConditions[i].Type == "project_points" && wish.UnlockConditions[i].ProjectID != nil { condition = &wish.UnlockConditions[i] projectID = *condition.ProjectID break } } } } if condition == nil || projectID == 0 { continue } minGoalScore := projectMinGoalScores[projectID] dailyScore := minGoalScore / 7.0 required := 0.0 current := 0.0 if condition.RequiredPoints != nil { required = *condition.RequiredPoints } if condition.CurrentPoints != nil { current = *condition.CurrentPoints } overflow := current - required log.Printf(" Wish id=%d name='%s' unlocked=%v lockedCount=%d projectID=%d required=%.2f current=%.2f overflow=%.2f dailyScore=%.2f", wish.ID, wish.Name, wish.Unlocked, wish.LockedConditionsCount, projectID, required, current, overflow, dailyScore) if wish.Unlocked { // Для разблокированных: показываем только если перебор >= 0 и <= 1 день if overflow >= 0 && overflow <= dailyScore { log.Printf(" -> ADDED as ready (unlocked)") projectWishes[projectID] = append(projectWishes[projectID], wishWithOverflow{ wish: wish, overflow: overflow, isReady: true, }) } else { log.Printf(" -> SKIPPED (unlocked but overflow out of range)") } } else { // Для заблокированных: должно быть только одно условие (или 0 если условие уже выполнено) if wish.LockedConditionsCount > 1 { log.Printf(" -> SKIPPED (lockedCount > 1)") continue } // Проверяем, выполнено ли условие (баллов достаточно с перебором <= 1 день) // Такие желания показываем как готовые if overflow >= 0 && overflow <= dailyScore { log.Printf(" -> ADDED as ready (locked but condition met)") projectWishes[projectID] = append(projectWishes[projectID], wishWithOverflow{ wish: wish, overflow: overflow, isReady: true, }) continue } // Иначе проверяем срок <=1 неделя (только если LockedConditionsCount == 1) if wish.LockedConditionsCount != 1 { log.Printf(" -> SKIPPED (lockedCount != 1 and not ready)") continue } weeksText := "" if condition.WeeksText != nil { weeksText = *condition.WeeksText } log.Printf(" weeksText='%s'", weeksText) if weeksText == "1 неделя" || weeksText == "<1 недели" { log.Printf(" -> ADDED as normal (weeks match)") projectWishes[projectID] = append(projectWishes[projectID], wishWithOverflow{ wish: wish, overflow: overflow, isReady: false, }) } else { log.Printf(" -> SKIPPED (weeks don't match)") } } } // Собираем результат: добавляем все желания, помечая готовые var result []WishlistItem for _, wishList := range projectWishes { // Добавляем все желания в результат for i := range wishList { w := wishList[i] if w.isReady { w.wish.IsReady = true } result = append(result, w.wish) } } // Сортируем результат: готовые первыми, затем по сроку разблокировки, затем по алфавиту sort.Slice(result, func(i, j int) bool { // Готовые желания первыми if result[i].IsReady && !result[j].IsReady { return true } if !result[i].IsReady && result[j].IsReady { return false } // Получаем weeks_text для сортировки по сроку getWeeksValue := func(w WishlistItem) float64 { var weeksText string if w.FirstLockedCondition != nil && w.FirstLockedCondition.WeeksText != nil { weeksText = *w.FirstLockedCondition.WeeksText } else { // Для разблокированных желаний ищем в unlock_conditions for _, cond := range w.UnlockConditions { if cond.Type == "project_points" && cond.WeeksText != nil { weeksText = *cond.WeeksText break } } } if weeksText == "" { return -1 // Готовые (пустой срок) идут первыми среди своей группы } if weeksText == "<1 недели" { return 0.5 } if weeksText == "1 неделя" { return 1 } return 99999 // Для остальных сроков } weeksI := getWeeksValue(result[i]) weeksJ := getWeeksValue(result[j]) if weeksI != weeksJ { return weeksI < weeksJ } // При одинаковом сроке сортируем по алфавиту if result[i].Name != result[j].Name { return result[i].Name < result[j].Name } // При одинаковом имени сортируем по ID для стабильности return result[i].ID < result[j].ID }) log.Printf("filterWishesForWeekProgress: returning %d wishes", len(result)) for _, w := range result { log.Printf(" Result: id=%d name='%s' isReady=%v", w.ID, w.Name, w.IsReady) } return result } // getConditionUnlockWeeks возвращает количество недель для разблокировки условия // Используется для сортировки заблокированных условий по баллам func (a *App) getConditionUnlockWeeks(cond UnlockConditionDisplay, userID int) float64 { if cond.Type != "project_points" { return 0 } if cond.ProjectID == nil || cond.RequiredPoints == nil { return 99999.0 } var startDate sql.NullTime if cond.StartDate != nil { date, err := time.Parse("2006-01-02", *cond.StartDate) if err == nil { startDate = sql.NullTime{Time: date, Valid: true} } } conditionOwnerID := userID if cond.UserID != nil { conditionOwnerID = *cond.UserID } return a.calculateProjectUnlockWeeks(*cond.ProjectID, *cond.RequiredPoints, startDate, conditionOwnerID) } // sortUnlockConditions сортирует условия в следующем порядке: // 1. Заблокированные задачи (по алфавиту) // 2. Заблокированные баллы (по сроку от меньшего к большему) // 3. Разблокированные задачи (по алфавиту) // 4. Разблокированные баллы (по алфавиту) func (a *App) sortUnlockConditions(conditions []UnlockConditionDisplay, userID int) { sort.Slice(conditions, func(i, j int) bool { condI := conditions[i] condJ := conditions[j] lockedI := isConditionLocked(condI) lockedJ := isConditionLocked(condJ) // 1. Заблокированные идут перед разблокированными if lockedI != lockedJ { return lockedI // lockedI == true идет первым } // Если оба заблокированы или оба разблокированы, сортируем по типу if lockedI { // Заблокированные: задачи идут перед баллами if condI.Type == "task_completion" && condJ.Type == "project_points" { return true } if condI.Type == "project_points" && condJ.Type == "task_completion" { return false } // Если оба одного типа if condI.Type == "task_completion" { // Заблокированные задачи: по алфавиту taskNameI := "" taskNameJ := "" if condI.TaskName != nil { taskNameI = *condI.TaskName } if condJ.TaskName != nil { taskNameJ = *condJ.TaskName } if taskNameI != taskNameJ { return taskNameI < taskNameJ } return condI.ID < condJ.ID } else { // Заблокированные баллы: по сроку от меньшего к большему weeksI := a.getConditionUnlockWeeks(condI, userID) weeksJ := a.getConditionUnlockWeeks(condJ, userID) if weeksI != weeksJ { return weeksI < weeksJ } // Если сроки равны, сортируем по алфавиту по названию проекта projectNameI := "" projectNameJ := "" if condI.ProjectName != nil { projectNameI = *condI.ProjectName } if condJ.ProjectName != nil { projectNameJ = *condJ.ProjectName } if projectNameI != projectNameJ { return projectNameI < projectNameJ } return condI.ID < condJ.ID } } else { // Разблокированные: задачи идут перед баллами if condI.Type == "task_completion" && condJ.Type == "project_points" { return true } if condI.Type == "project_points" && condJ.Type == "task_completion" { return false } // Если оба одного типа, сортируем по алфавиту if condI.Type == "task_completion" { // Разблокированные задачи: по алфавиту taskNameI := "" taskNameJ := "" if condI.TaskName != nil { taskNameI = *condI.TaskName } if condJ.TaskName != nil { taskNameJ = *condJ.TaskName } if taskNameI != taskNameJ { return taskNameI < taskNameJ } return condI.ID < condJ.ID } else { // Разблокированные баллы: по алфавиту projectNameI := "" projectNameJ := "" if condI.ProjectName != nil { projectNameI = *condI.ProjectName } if condJ.ProjectName != nil { projectNameJ = *condJ.ProjectName } if projectNameI != projectNameJ { return projectNameI < projectNameJ } return condI.ID < condJ.ID } } }) } // getWishlistItemsWithConditions загружает желания с их условиями func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) ([]WishlistItem, error) { query := ` SELECT wi.id, wi.name, wi.price, wi.image_path, wi.link, wi.completed, wi.rejected, wi.group_name, wc.id AS condition_id, wc.display_order, wc.task_condition_id, wc.score_condition_id, wc.user_id AS condition_user_id, tc.task_id, t.name AS task_name, sc.project_id, p.name AS project_name, sc.required_points, sc.start_date FROM wishlist_items wi LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE WHERE wi.user_id = $1 AND wi.deleted = FALSE AND ($2 = TRUE OR wi.completed = FALSE) ORDER BY wi.completed, wi.id, wc.display_order, wc.id ` rows, err := a.DB.Query(query, userID, includeCompleted) if err != nil { return nil, err } defer rows.Close() // Группируем по wishlist_item_id itemsMap := make(map[int]*WishlistItem) for rows.Next() { var itemID int var name string var price sql.NullFloat64 var imagePath, link sql.NullString var completed bool var rejected bool var groupName sql.NullString var conditionID, displayOrder sql.NullInt64 var taskConditionID, scoreConditionID sql.NullInt64 var conditionUserID sql.NullInt64 var taskID sql.NullInt64 var taskName sql.NullString var projectID sql.NullInt64 var projectName sql.NullString var requiredPoints sql.NullFloat64 var startDate sql.NullTime err := rows.Scan( &itemID, &name, &price, &imagePath, &link, &completed, &rejected, &groupName, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, ) if err != nil { return nil, err } // Получаем или создаём item item, exists := itemsMap[itemID] if !exists { item = &WishlistItem{ ID: itemID, Name: name, Completed: completed, Rejected: rejected, UnlockConditions: []UnlockConditionDisplay{}, } if price.Valid { p := price.Float64 item.Price = &p } if imagePath.Valid { url := imagePath.String item.ImageURL = &url } if link.Valid { l := link.String item.Link = &l } if groupName.Valid && groupName.String != "" { groupNameVal := groupName.String item.GroupName = &groupNameVal } itemsMap[itemID] = item } // Добавляем условие, если есть if conditionID.Valid { // Определяем владельца условия conditionOwnerID := userID if conditionUserID.Valid { conditionOwnerID = int(conditionUserID.Int64) } // Если это условие по задаче, проверяем существует ли задача if taskConditionID.Valid && taskID.Valid { // Проверяем, существует ли задача (не удалена) var taskExists bool err := a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)`, taskID.Int64, conditionOwnerID).Scan(&taskExists) if err != nil || !taskExists { // Задача удалена - не добавляем условие в список, но при проверке блокировки оно считается выполненным continue } } condition := UnlockConditionDisplay{ ID: int(conditionID.Int64), DisplayOrder: int(displayOrder.Int64), } // Заполняем UserID для условия if conditionUserID.Valid { conditionOwnerID := int(conditionUserID.Int64) condition.UserID = &conditionOwnerID } else { condition.UserID = &userID } if taskConditionID.Valid { condition.Type = "task_completion" if taskName.Valid { condition.TaskName = &taskName.String } if taskID.Valid { taskIDVal := int(taskID.Int64) condition.TaskID = &taskIDVal } } else if scoreConditionID.Valid { condition.Type = "project_points" if projectName.Valid { condition.ProjectName = &projectName.String } if projectID.Valid { projectIDVal := int(projectID.Int64) condition.ProjectID = &projectIDVal } if requiredPoints.Valid { condition.RequiredPoints = &requiredPoints.Float64 } if startDate.Valid { // Форматируем дату в YYYY-MM-DD dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } } item.UnlockConditions = append(item.UnlockConditions, condition) } } // Конвертируем map в slice и проверяем разблокировку items := make([]WishlistItem, 0, len(itemsMap)) for _, item := range itemsMap { unlocked, err := a.checkWishlistUnlock(item.ID, userID) if err != nil { log.Printf("Error checking unlock for wishlist %d: %v", item.ID, err) unlocked = false } item.Unlocked = unlocked // Сортируем условия в нужном порядке a.sortUnlockConditions(item.UnlockConditions, userID) // Определяем первое заблокированное условие и количество остальных, а также рассчитываем прогресс if !unlocked && !item.Completed { lockedCount := 0 var firstLocked *UnlockConditionDisplay for i := range item.UnlockConditions { // Проверяем каждое условие отдельно condition := &item.UnlockConditions[i] var conditionMet bool var err error if condition.Type == "task_completion" { // Находим task_id и user_id для этого условия var taskID int var conditionOwnerID int err = a.DB.QueryRow(` SELECT tc.task_id, COALESCE(wc.user_id, $2) FROM wishlist_conditions wc JOIN task_conditions tc ON wc.task_condition_id = tc.id WHERE wc.id = $1 `, condition.ID, userID).Scan(&taskID, &conditionOwnerID) if err == nil { var completed int err = a.DB.QueryRow(` SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE `, taskID, conditionOwnerID).Scan(&completed) if err == sql.ErrNoRows { // Задача удалена или не существует - не блокируем желание conditionMet = true completedBool := true condition.TaskCompleted = &completedBool } else if err == nil { // Задача существует и не удалена — условие не выполнено conditionMet = false completedBool := false condition.TaskCompleted = &completedBool } } } else if condition.Type == "project_points" { // Находим project_id, required_points и user_id для этого условия var projectID int var requiredPoints float64 var startDate sql.NullTime var conditionOwnerID int err = a.DB.QueryRow(` SELECT sc.project_id, sc.required_points, sc.start_date, COALESCE(wc.user_id, $2) FROM wishlist_conditions wc JOIN score_conditions sc ON wc.score_condition_id = sc.id WHERE wc.id = $1 `, condition.ID, userID).Scan(&projectID, &requiredPoints, &startDate, &conditionOwnerID) if err == nil { totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, conditionOwnerID) if err != nil { // Если ошибка при расчете, устанавливаем 0 zeroScore := 0.0 condition.CurrentPoints = &zeroScore conditionMet = false } else { condition.CurrentPoints = &totalScore conditionMet = totalScore >= requiredPoints } // Рассчитываем и форматируем срок разблокировки для заблокированных условий if condition.ProjectID != nil && condition.RequiredPoints != nil { weeks := a.calculateProjectUnlockWeeks( projectID, requiredPoints, startDate, conditionOwnerID, ) weeksText := formatWeeksText(weeks) condition.WeeksText = &weeksText } } } if !conditionMet { lockedCount++ if firstLocked == nil { firstLocked = condition } } } if firstLocked != nil { item.FirstLockedCondition = firstLocked item.MoreLockedConditions = lockedCount - 1 item.LockedConditionsCount = lockedCount } } else { // Даже если желание разблокировано, рассчитываем прогресс для всех условий for i := range item.UnlockConditions { condition := &item.UnlockConditions[i] if condition.Type == "task_completion" { var taskID int var conditionOwnerID int err := a.DB.QueryRow(` SELECT tc.task_id, COALESCE(wc.user_id, $2) FROM wishlist_conditions wc JOIN task_conditions tc ON wc.task_condition_id = tc.id WHERE wc.id = $1 `, condition.ID, userID).Scan(&taskID, &conditionOwnerID) if err == nil { var completed int err = a.DB.QueryRow(` SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE `, taskID, conditionOwnerID).Scan(&completed) if err == sql.ErrNoRows { // Задача удалена или не существует - не блокируем желание completedBool := true condition.TaskCompleted = &completedBool } else if err == nil { // Задача существует и не удалена — условие не выполнено completedBool := false condition.TaskCompleted = &completedBool } } } else if condition.Type == "project_points" { var projectID int var requiredPoints float64 var startDate sql.NullTime var conditionOwnerID int err := a.DB.QueryRow(` SELECT sc.project_id, sc.required_points, sc.start_date, COALESCE(wc.user_id, $2) FROM wishlist_conditions wc JOIN score_conditions sc ON wc.score_condition_id = sc.id WHERE wc.id = $1 `, condition.ID, userID).Scan(&projectID, &requiredPoints, &startDate, &conditionOwnerID) if err == nil { totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, conditionOwnerID) if err != nil { // Если ошибка при расчете, устанавливаем 0 zeroScore := 0.0 condition.CurrentPoints = &zeroScore } else { condition.CurrentPoints = &totalScore } // Рассчитываем и форматируем срок разблокировки if condition.ProjectID != nil && condition.RequiredPoints != nil { weeks := a.calculateProjectUnlockWeeks( projectID, requiredPoints, startDate, conditionOwnerID, ) weeksText := formatWeeksText(weeks) condition.WeeksText = &weeksText } } } } } // Загружаем связанную задачу текущего пользователя, если есть var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64 var linkedTaskName sql.NullString var linkedTaskNextShowAt sql.NullTime linkedTaskErr := a.DB.QueryRow(` SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id FROM tasks t WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE LIMIT 1 `, item.ID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) if linkedTaskErr == nil && linkedTaskID.Valid { linkedTask := &LinkedTask{ ID: int(linkedTaskID.Int64), Name: linkedTaskName.String, Completed: int(linkedTaskCompleted.Int64), } if linkedTaskNextShowAt.Valid { nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339) linkedTask.NextShowAt = &nextShowAtStr } if linkedTaskUserID.Valid { userIDVal := int(linkedTaskUserID.Int64) linkedTask.UserID = &userIDVal } item.LinkedTask = linkedTask } else if linkedTaskErr != sql.ErrNoRows { log.Printf("Error loading linked task for wishlist %d: %v", item.ID, linkedTaskErr) // Не возвращаем ошибку, просто не устанавливаем linked_task } // Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей) // Исключаем linked_task из подсчета, если она есть // Учитываем только не закрытые задачи (completed = 0) var tasksCount int if linkedTaskID.Valid { // Если есть linked_task, исключаем её из подсчета err = a.DB.QueryRow(` SELECT COUNT(*) FROM tasks t WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2 `, item.ID, linkedTaskID.Int64).Scan(&tasksCount) } else { // Если нет linked_task, считаем все не закрытые задачи err = a.DB.QueryRow(` SELECT COUNT(*) FROM tasks t WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 `, item.ID).Scan(&tasksCount) } if err != nil { log.Printf("Error counting tasks for wishlist %d: %v", item.ID, err) tasksCount = 0 } item.TasksCount = tasksCount items = append(items, *item) } return items, nil } // saveWishlistConditions сохраняет условия для желания // userID - автор условий (пользователь, который создает/обновляет условия) func (a *App) saveWishlistConditions( tx *sql.Tx, wishlistItemID int, userID int, conditions []UnlockConditionRequest, ) error { // Получаем все существующие условия с их user_id перед удалением existingConditions := make(map[int]int) // map[conditionID]userID rows, err := tx.Query(` SELECT id, user_id FROM wishlist_conditions WHERE wishlist_item_id = $1 `, wishlistItemID) if err != nil { return fmt.Errorf("error getting existing conditions: %w", err) } defer rows.Close() for rows.Next() { var condID int var condUserID sql.NullInt64 if err := rows.Scan(&condID, &condUserID); err != nil { return fmt.Errorf("error scanning existing condition: %w", err) } if condUserID.Valid { existingConditions[condID] = int(condUserID.Int64) } } // Удаляем только условия текущего пользователя _, err = tx.Exec(` DELETE FROM wishlist_conditions WHERE wishlist_item_id = $1 AND user_id = $2 `, wishlistItemID, userID) if err != nil { return fmt.Errorf("error deleting user conditions: %w", err) } if len(conditions) == 0 { return nil } // Подготавливаем statement для вставки условий stmt, err := tx.Prepare(` INSERT INTO wishlist_conditions (wishlist_item_id, user_id, task_condition_id, score_condition_id, display_order) VALUES ($1, $2, $3, $4, $5) `) if err != nil { return err } defer stmt.Close() for i, condition := range conditions { displayOrder := i if condition.DisplayOrder != nil { displayOrder = *condition.DisplayOrder } var taskConditionID interface{} var scoreConditionID interface{} if condition.Type == "task_completion" { if condition.TaskID == nil { return fmt.Errorf("task_id is required for task_completion") } // Получаем или создаём task_condition var tcID int err := tx.QueryRow(` SELECT id FROM task_conditions WHERE task_id = $1 `, *condition.TaskID).Scan(&tcID) if err == sql.ErrNoRows { // Создаём новое условие err = tx.QueryRow(` INSERT INTO task_conditions (task_id) VALUES ($1) ON CONFLICT (task_id) DO UPDATE SET task_id = EXCLUDED.task_id RETURNING id `, *condition.TaskID).Scan(&tcID) if err != nil { return err } } else if err != nil { return err } taskConditionID = tcID } else if condition.Type == "project_points" { if condition.ProjectID == nil || condition.RequiredPoints == nil { return fmt.Errorf("project_id and required_points are required for project_points") } startDateStr := condition.StartDate // Получаем или создаём score_condition var scID int var startDateVal interface{} if startDateStr != nil && *startDateStr != "" { // Парсим дату из строки YYYY-MM-DD startDateVal = *startDateStr } else { // Пустая строка или nil = NULL для "за всё время" startDateVal = nil } err := tx.QueryRow(` SELECT id FROM score_conditions WHERE project_id = $1 AND required_points = $2 AND (start_date = $3::DATE OR (start_date IS NULL AND $3 IS NULL)) `, *condition.ProjectID, *condition.RequiredPoints, startDateVal).Scan(&scID) if err == sql.ErrNoRows { // Создаём новое условие err = tx.QueryRow(` INSERT INTO score_conditions (project_id, required_points, start_date) VALUES ($1, $2, $3::DATE) ON CONFLICT (project_id, required_points, start_date) DO UPDATE SET project_id = EXCLUDED.project_id RETURNING id `, *condition.ProjectID, *condition.RequiredPoints, startDateVal).Scan(&scID) if err != nil { return err } } else if err != nil { return err } scoreConditionID = scID } // Определяем user_id для условия: // - Если условие имеет id и это условие существовало - проверяем, принадлежит ли оно текущему пользователю // - Если условие принадлежит другому пользователю - пропускаем (не сохраняем, так как чужие условия не редактируются) // - Если условие имеет id, но не существовало (например, было только что добавлено) - это новое условие, используем userID текущего пользователя // - Если условие без id - это новое условие, используем userID текущего пользователя conditionUserID := userID if condition.ID != nil { if originalUserID, exists := existingConditions[*condition.ID]; exists { // Если условие принадлежит другому пользователю - пропускаем (не сохраняем, так как чужие условия не редактируются) if originalUserID != userID { continue } // Условие принадлежит текущему пользователю - обновляем его conditionUserID = originalUserID } else { // Условие имеет id, но не существует в базе - это новое условие, используем userID текущего пользователя conditionUserID = userID } } // Создаём связь _, err = stmt.Exec( wishlistItemID, conditionUserID, taskConditionID, scoreConditionID, displayOrder, ) if err != nil { return err } } return nil } // getWishlistHandler возвращает список незавершённых желаний и счётчик завершённых func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Загружаем только незавершённые items, err := a.getWishlistItemsWithConditions(userID, false) if err != nil { log.Printf("Error getting wishlist items: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist items: %v", err), http.StatusInternalServerError) return } // Получаем количество завершённых var completedCount int err = a.DB.QueryRow(` SELECT COUNT(*) FROM wishlist_items WHERE user_id = $1 AND deleted = FALSE AND completed = TRUE `, userID).Scan(&completedCount) if err != nil { log.Printf("Error counting completed wishlist items: %v", err) completedCount = 0 } // Группируем и сортируем unlocked := make([]WishlistItem, 0) locked := make([]WishlistItem, 0) for _, item := range items { if item.Unlocked { unlocked = append(unlocked, item) } else { locked = append(locked, item) } } // Сортируем разблокированные по цене от меньшего к большему sort.Slice(unlocked, func(i, j int) bool { priceI := 0.0 priceJ := 0.0 if unlocked[i].Price != nil { priceI = *unlocked[i].Price } if unlocked[j].Price != nil { priceJ = *unlocked[j].Price } if priceI == priceJ { return unlocked[i].ID < unlocked[j].ID } return priceI < priceJ // Сортировка по цене от меньшего к большему (заменяет calculateUnlockedSortValue) }) // Разделяем заблокированные на группы lockedWithoutTasks := []WishlistItem{} lockedWithTasks := []WishlistItem{} for _, item := range locked { hasUncompletedTasks := false for _, cond := range item.UnlockConditions { if cond.Type == "task_completion" && (cond.TaskCompleted == nil || !*cond.TaskCompleted) { hasUncompletedTasks = true break } } if hasUncompletedTasks { lockedWithTasks = append(lockedWithTasks, item) } else { lockedWithoutTasks = append(lockedWithoutTasks, item) } } // Сортируем каждую группу по времени разблокировки (от меньшего срока к большему) sort.Slice(lockedWithoutTasks, func(i, j int) bool { valueI := a.calculateLockedSortValue(lockedWithoutTasks[i], userID) valueJ := a.calculateLockedSortValue(lockedWithoutTasks[j], userID) if valueI == valueJ { return lockedWithoutTasks[i].ID < lockedWithoutTasks[j].ID } return valueI < valueJ }) sort.Slice(lockedWithTasks, func(i, j int) bool { valueI := a.calculateLockedSortValue(lockedWithTasks[i], userID) valueJ := a.calculateLockedSortValue(lockedWithTasks[j], userID) if valueI == valueJ { return lockedWithTasks[i].ID < lockedWithTasks[j].ID } return valueI < valueJ }) // Объединяем: сначала без задач, потом с задачами locked = append(lockedWithoutTasks, lockedWithTasks...) response := WishlistResponse{ Unlocked: unlocked, Locked: locked, CompletedCount: completedCount, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // getWishlistCompletedHandler возвращает список завершённых желаний func (a *App) getWishlistCompletedHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Загружаем все желания включая завершённые items, err := a.getWishlistItemsWithConditions(userID, true) if err != nil { log.Printf("Error getting completed wishlist items: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error getting completed wishlist items: %v", err), http.StatusInternalServerError) return } // Фильтруем только завершённые completed := make([]WishlistItem, 0) for _, item := range items { if item.Completed { completed = append(completed, item) } } // Сортируем по цене (дорогие → дешёвые) sort.Slice(completed, func(i, j int) bool { priceI := 0.0 priceJ := 0.0 if completed[i].Price != nil { priceI = *completed[i].Price } if completed[j].Price != nil { priceJ = *completed[j].Price } return priceI > priceJ }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(completed) } // createWishlistHandler создаёт новое желание func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { log.Printf("createWishlistHandler: Unauthorized") sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } log.Printf("createWishlistHandler: userID=%d", userID) var req WishlistRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("createWishlistHandler: Error decoding wishlist request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } 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{} } for i, cond := range req.UnlockConditions { log.Printf("createWishlistHandler: condition %d - type='%s', task_id=%v, project_id=%v, required_points=%v, start_date='%v'", i, cond.Type, cond.TaskID, cond.ProjectID, cond.RequiredPoints, cond.StartDate) } if strings.TrimSpace(req.Name) == "" { log.Printf("createWishlistHandler: Name is required") sendErrorWithCORS(w, "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 wishlistID int err = tx.QueryRow(` INSERT INTO wishlist_items (user_id, author_id, name, price, link, group_name, completed, deleted) VALUES ($1, $1, $2, $3, $4, $5, FALSE, FALSE) RETURNING id `, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.GroupName).Scan(&wishlistID) if err != nil { log.Printf("Error creating wishlist item: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist item: %v", err), http.StatusInternalServerError) return } // Сохраняем условия if len(req.UnlockConditions) > 0 { log.Printf("createWishlistHandler: saving %d conditions", len(req.UnlockConditions)) err = a.saveWishlistConditionsWithUserID(tx, wishlistID, userID, req.UnlockConditions) if err != nil { log.Printf("createWishlistHandler: Error saving wishlist conditions: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError) return } log.Printf("createWishlistHandler: conditions saved successfully") } else { log.Printf("createWishlistHandler: no conditions to save") } log.Printf("createWishlistHandler: committing transaction") if err := tx.Commit(); err != nil { log.Printf("createWishlistHandler: Error committing transaction: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) return } log.Printf("createWishlistHandler: transaction committed successfully") // Обновляем MV для групповых саджестов if req.GroupName != nil && *req.GroupName != "" { if err := a.refreshGroupSuggestionsMV(); err != nil { log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) } } // Получаем созданное желание с условиями items, err := a.getWishlistItemsWithConditions(userID, false) if err != nil { log.Printf("Error getting created wishlist item: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error getting created wishlist item: %v", err), http.StatusInternalServerError) return } var createdItem *WishlistItem for i := range items { if items[i].ID == wishlistID { createdItem = &items[i] break } } if createdItem == nil { log.Printf("createWishlistHandler: Created item not found") sendErrorWithCORS(w, "Created item not found", http.StatusInternalServerError) return } log.Printf("createWishlistHandler: Successfully created wishlist item id=%d, name='%s'", createdItem.ID, createdItem.Name) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(createdItem) } // checkWishlistAccess проверяет доступ пользователя к желанию // Возвращает (hasAccess, itemUserID, boardID, error) func (a *App) checkWishlistAccess(itemID int, userID int) (bool, int, sql.NullInt64, error) { var itemUserID int var boardID sql.NullInt64 err := a.DB.QueryRow(` SELECT user_id, board_id 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 { var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID.Int64).Scan(&ownerID) if err == nil { hasAccess = ownerID == userID if !hasAccess { var isMember bool err = a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, int(boardID.Int64), userID).Scan(&isMember) if err == nil { hasAccess = isMember } } } } return hasAccess, itemUserID, boardID, nil } // CalculateWeeksRequest структура запроса для расчета недель type CalculateWeeksRequest struct { ProjectID int `json:"project_id"` RequiredPoints float64 `json:"required_points"` StartDate string `json:"start_date,omitempty"` ConditionUserID *int `json:"condition_user_id,omitempty"` // Владелец условия (если условие существует) } // calculateWeeksHandler обрабатывает запрос на расчет недель для разблокировки условия func (a *App) calculateWeeksHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req CalculateWeeksRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Определяем владельца условия: // 1. Если передан condition_user_id в запросе - используем его (для существующего условия) // 2. Иначе используем текущего пользователя (для нового условия) conditionOwnerID := userID // userID из контекста (текущий пользователь) if req.ConditionUserID != nil && *req.ConditionUserID > 0 { conditionOwnerID = *req.ConditionUserID } var startDate sql.NullTime if req.StartDate != "" { date, err := time.Parse("2006-01-02", req.StartDate) if err == nil { startDate = sql.NullTime{Time: date, Valid: true} } } // Используем владельца условия, а не текущего пользователя weeks := a.calculateProjectUnlockWeeks(req.ProjectID, req.RequiredPoints, startDate, conditionOwnerID) response := map[string]interface{}{ "weeks_text": formatWeeksText(weeks), // Отформатированная строка для отображения } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // getWishlistItemHandler возвращает одно желание func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) return } // Проверяем доступ к желанию hasAccess, itemUserID, boardID, err := a.checkWishlistAccess(itemID, userID) if err == sql.ErrNoRows { log.Printf("Wishlist item not found: id=%d, userID=%d", itemID, userID) sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error getting wishlist item (id=%d, userID=%d): %v", itemID, userID, err) 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 { log.Printf("Access denied for wishlist item: id=%d, itemUserID=%d, boardID=%v, currentUserID=%d", itemID, itemUserID, boardID, userID) 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 itemOwnerID := itemUserID // Загружаем полную информацию о желании query := ` SELECT wi.id, wi.name, wi.price, wi.image_path, wi.link, wi.completed, wi.rejected, wi.group_name, wc.id AS condition_id, wc.display_order, wc.task_condition_id, wc.score_condition_id, wc.user_id AS condition_user_id, tc.task_id, t.name AS task_name, t.next_show_at AS task_next_show_at, sc.project_id, p.name AS project_name, sc.required_points, sc.start_date FROM wishlist_items wi LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE WHERE wi.id = $1 AND wi.deleted = FALSE ORDER BY wc.display_order, wc.id ` rows, err := a.DB.Query(query, itemID) if err != nil { log.Printf("Error querying wishlist item: %v", err) sendErrorWithCORS(w, "Error getting wishlist item", http.StatusInternalServerError) return } defer rows.Close() itemsMap := make(map[int]*WishlistItem) for rows.Next() { var itemID int var name string var price sql.NullFloat64 var imagePath sql.NullString var link sql.NullString var completed bool var rejected bool var groupName sql.NullString var conditionID sql.NullInt64 var displayOrder sql.NullInt64 var taskConditionID sql.NullInt64 var scoreConditionID sql.NullInt64 var conditionUserID sql.NullInt64 var taskID sql.NullInt64 var taskName sql.NullString var taskNextShowAt sql.NullTime var projectID sql.NullInt64 var projectName sql.NullString var requiredPoints sql.NullFloat64 var startDate sql.NullTime err := rows.Scan( &itemID, &name, &price, &imagePath, &link, &completed, &rejected, &groupName, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, &taskNextShowAt, &projectID, &projectName, &requiredPoints, &startDate, ) if err != nil { log.Printf("Error scanning wishlist item: %v", err) continue } item, exists := itemsMap[itemID] if !exists { item = &WishlistItem{ ID: itemID, Name: name, Completed: completed, Rejected: rejected, UnlockConditions: []UnlockConditionDisplay{}, } if price.Valid { item.Price = &price.Float64 } if imagePath.Valid && imagePath.String != "" { url := imagePath.String if !strings.HasPrefix(url, "http") { url = url + "?t=" + strconv.FormatInt(time.Now().Unix(), 10) } item.ImageURL = &url } if link.Valid { item.Link = &link.String } if groupName.Valid && groupName.String != "" { groupNameVal := groupName.String item.GroupName = &groupNameVal } itemsMap[itemID] = item } if conditionID.Valid { // Используем user_id из условия, если он есть, иначе используем владельца желания // Это важно для старых условий, созданных до добавления user_id в wishlist_conditions conditionOwnerID := itemOwnerID if conditionUserID.Valid { conditionOwnerID = int(conditionUserID.Int64) } // Если это условие по задаче, проверяем существует ли задача if taskConditionID.Valid && taskID.Valid { // Проверяем, существует ли задача (не удалена) var taskExists bool err := a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)`, taskID.Int64, conditionOwnerID).Scan(&taskExists) if err != nil || !taskExists { // Задача удалена - не добавляем условие в список, но при проверке блокировки оно считается выполненным continue } } condition := UnlockConditionDisplay{ ID: int(conditionID.Int64), DisplayOrder: int(displayOrder.Int64), } if conditionUserID.Valid { conditionOwnerID := int(conditionUserID.Int64) condition.UserID = &conditionOwnerID } else { condition.UserID = &itemOwnerID } if taskConditionID.Valid { condition.Type = "task_completion" if taskName.Valid { condition.TaskName = &taskName.String } if taskID.Valid { taskIDVal := int(taskID.Int64) condition.TaskID = &taskIDVal var taskCompleted int err := a.DB.QueryRow(`SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE`, taskID.Int64, conditionOwnerID).Scan(&taskCompleted) if err == sql.ErrNoRows { // Задача удалена или не существует — условие выполнено t := true condition.TaskCompleted = &t } else if err == nil { // Задача существует и не удалена — условие не выполнено f := false condition.TaskCompleted = &f } } if taskNextShowAt.Valid { nextShowAtStr := taskNextShowAt.Time.Format(time.RFC3339) condition.TaskNextShowAt = &nextShowAtStr } } else if scoreConditionID.Valid { condition.Type = "project_points" if projectName.Valid { condition.ProjectName = &projectName.String } if projectID.Valid { projectIDVal := int(projectID.Int64) condition.ProjectID = &projectIDVal points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID) condition.CurrentPoints = &points } if requiredPoints.Valid { condition.RequiredPoints = &requiredPoints.Float64 } if startDate.Valid { dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } // Рассчитываем и форматируем срок разблокировки if condition.ProjectID != nil && condition.RequiredPoints != nil { weeks := a.calculateProjectUnlockWeeks( *condition.ProjectID, *condition.RequiredPoints, startDate, conditionOwnerID, ) weeksText := formatWeeksText(weeks) condition.WeeksText = &weeksText } } item.UnlockConditions = append(item.UnlockConditions, condition) } } // Получаем желание из map var item *WishlistItem for _, it := range itemsMap { if it.ID == itemID { item = it break } } if item == nil { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } // Проверяем разблокировку item.Unlocked = true if len(item.UnlockConditions) > 0 { for _, cond := range item.UnlockConditions { if cond.Type == "task_completion" { if cond.TaskCompleted == nil || !*cond.TaskCompleted { item.Unlocked = false break } } else if cond.Type == "project_points" { if cond.CurrentPoints == nil || cond.RequiredPoints == nil || *cond.CurrentPoints < *cond.RequiredPoints { item.Unlocked = false break } } } } // Также проверяем через checkWishlistUnlock для совместимости unlocked, err := a.checkWishlistUnlock(itemID, userID) if err == nil { item.Unlocked = unlocked } // Сортируем условия в нужном порядке a.sortUnlockConditions(item.UnlockConditions, userID) // Загружаем связанную задачу текущего пользователя, если есть var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64 var linkedTaskName sql.NullString var linkedTaskNextShowAt sql.NullTime err = a.DB.QueryRow(` SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id FROM tasks t WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE LIMIT 1 `, itemID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) if err == nil && linkedTaskID.Valid { linkedTask := &LinkedTask{ ID: int(linkedTaskID.Int64), Name: linkedTaskName.String, Completed: int(linkedTaskCompleted.Int64), } if linkedTaskNextShowAt.Valid { nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339) linkedTask.NextShowAt = &nextShowAtStr } if linkedTaskUserID.Valid { userIDVal := int(linkedTaskUserID.Int64) linkedTask.UserID = &userIDVal } item.LinkedTask = linkedTask } else if err != sql.ErrNoRows { log.Printf("Error loading linked task for wishlist %d: %v", itemID, err) // Не возвращаем ошибку, просто не устанавливаем linked_task } // Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей) // Исключаем linked_task из подсчета, если она есть // Учитываем только не закрытые задачи (completed = 0) var tasksCount int if linkedTaskID.Valid { // Если есть linked_task, исключаем её из подсчета err = a.DB.QueryRow(` SELECT COUNT(*) FROM tasks t WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2 `, itemID, linkedTaskID.Int64).Scan(&tasksCount) } else { // Если нет linked_task, считаем все не закрытые задачи err = a.DB.QueryRow(` SELECT COUNT(*) FROM tasks t WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 `, itemID).Scan(&tasksCount) } if err != nil { log.Printf("Error counting tasks for wishlist %d: %v", itemID, err) tasksCount = 0 } item.TasksCount = tasksCount w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(item) } // updateWishlistHandler обновляет желание func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) log.Printf("updateWishlistHandler called: method=%s, path=%s", r.Method, r.URL.Path) userID, ok := getUserIDFromContext(r) if !ok { log.Printf("updateWishlistHandler: Unauthorized") sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { log.Printf("updateWishlistHandler: Invalid wishlist ID: %v", err) sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) return } log.Printf("updateWishlistHandler: itemID=%d, userID=%d", itemID, userID) // Проверяем доступ к желанию hasAccess, _, _, err := a.checkWishlistAccess(itemID, userID) if err == sql.ErrNoRows { log.Printf("updateWishlistHandler: Wishlist item not found: id=%d, userID=%d", itemID, userID) sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } if err != nil { log.Printf("updateWishlistHandler: Error getting wishlist item (id=%d, userID=%d): %v", itemID, userID, err) sendErrorWithCORS(w, "Error getting wishlist item", http.StatusInternalServerError) return } if !hasAccess { log.Printf("updateWishlistHandler: Access denied: id=%d, userID=%d", itemID, userID) sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } log.Printf("updateWishlistHandler: Access granted: id=%d, userID=%d", itemID, userID) var req WishlistRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding wishlist request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if strings.TrimSpace(req.Name) == "" { sendErrorWithCORS(w, "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() // Обновляем желание (не проверяем user_id в WHERE, так как доступ уже проверен выше) _, err = tx.Exec(` UPDATE wishlist_items SET name = $1, price = $2, link = $3, group_name = $4, updated_at = NOW() WHERE id = $5 `, strings.TrimSpace(req.Name), req.Price, req.Link, req.GroupName, itemID) if err != nil { log.Printf("Error updating wishlist item: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error updating wishlist item: %v", err), http.StatusInternalServerError) return } // Сохраняем условия err = a.saveWishlistConditions(tx, itemID, userID, req.UnlockConditions) if err != nil { log.Printf("Error saving wishlist conditions: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %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 } // Обновляем MV для групповых саджестов if req.GroupName != nil && *req.GroupName != "" { if err := a.refreshGroupSuggestionsMV(); err != nil { log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) } } // Получаем обновлённое желание через getWishlistItemHandler логику // Используем тот же запрос, что и в getWishlistItemHandler query := ` SELECT wi.id, wi.name, wi.price, wi.image_path, wi.link, wi.completed, wi.rejected, wi.group_name, wc.id AS condition_id, wc.display_order, wc.task_condition_id, wc.score_condition_id, wc.user_id AS condition_user_id, tc.task_id, t.name AS task_name, sc.project_id, p.name AS project_name, sc.required_points, sc.start_date FROM wishlist_items wi LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE WHERE wi.id = $1 AND wi.deleted = FALSE ORDER BY wc.display_order, wc.id ` rows, err := a.DB.Query(query, itemID) if err != nil { log.Printf("Error querying updated wishlist item: %v", err) sendErrorWithCORS(w, "Error getting updated wishlist item", http.StatusInternalServerError) return } defer rows.Close() itemsMap := make(map[int]*WishlistItem) var itemOwnerID int for rows.Next() { var itemID int var name string var price sql.NullFloat64 var imagePath sql.NullString var link sql.NullString var completed bool var rejected bool var groupName sql.NullString var conditionID sql.NullInt64 var displayOrder sql.NullInt64 var taskConditionID sql.NullInt64 var scoreConditionID sql.NullInt64 var conditionUserID sql.NullInt64 var taskID sql.NullInt64 var taskName sql.NullString var projectID sql.NullInt64 var projectName sql.NullString var requiredPoints sql.NullFloat64 var startDate sql.NullTime err := rows.Scan( &itemID, &name, &price, &imagePath, &link, &completed, &rejected, &groupName, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, ) if err != nil { log.Printf("Error scanning updated wishlist item: %v", err) continue } item, exists := itemsMap[itemID] if !exists { // Получаем user_id для этого желания err = a.DB.QueryRow(`SELECT user_id FROM wishlist_items WHERE id = $1`, itemID).Scan(&itemOwnerID) if err != nil { log.Printf("Error getting item owner: %v", err) continue } item = &WishlistItem{ ID: itemID, Name: name, Completed: completed, Rejected: rejected, UnlockConditions: []UnlockConditionDisplay{}, } if price.Valid { item.Price = &price.Float64 } if imagePath.Valid && imagePath.String != "" { url := imagePath.String if !strings.HasPrefix(url, "http") { url = url + "?t=" + strconv.FormatInt(time.Now().Unix(), 10) } item.ImageURL = &url } if link.Valid { item.Link = &link.String } if groupName.Valid && groupName.String != "" { groupNameVal := groupName.String item.GroupName = &groupNameVal } itemsMap[itemID] = item } if conditionID.Valid { // Определяем владельца условия conditionOwnerID := itemOwnerID if conditionUserID.Valid { conditionOwnerID = int(conditionUserID.Int64) } // Если это условие по задаче, проверяем существует ли задача if taskConditionID.Valid && taskID.Valid { // Проверяем, существует ли задача (не удалена) var taskExists bool err := a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)`, taskID.Int64, conditionOwnerID).Scan(&taskExists) if err != nil || !taskExists { // Задача удалена - не добавляем условие в список, но при проверке блокировки оно считается выполненным continue } } condition := UnlockConditionDisplay{ ID: int(conditionID.Int64), DisplayOrder: int(displayOrder.Int64), } if conditionUserID.Valid { conditionOwnerID := int(conditionUserID.Int64) condition.UserID = &conditionOwnerID } else { condition.UserID = &itemOwnerID } if taskConditionID.Valid { condition.Type = "task_completion" if taskName.Valid { condition.TaskName = &taskName.String } if taskID.Valid { taskIDVal := int(taskID.Int64) condition.TaskID = &taskIDVal var taskCompleted int err := a.DB.QueryRow(`SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE`, taskID.Int64, conditionOwnerID).Scan(&taskCompleted) if err == sql.ErrNoRows { // Задача удалена или не существует — условие выполнено t := true condition.TaskCompleted = &t } else if err == nil { // Задача существует и не удалена — условие не выполнено f := false condition.TaskCompleted = &f } } } else if scoreConditionID.Valid { condition.Type = "project_points" if projectName.Valid { condition.ProjectName = &projectName.String } if projectID.Valid { projectIDVal := int(projectID.Int64) condition.ProjectID = &projectIDVal points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID) condition.CurrentPoints = &points } if requiredPoints.Valid { condition.RequiredPoints = &requiredPoints.Float64 } if startDate.Valid { dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } // Рассчитываем и форматируем срок разблокировки if condition.ProjectID != nil && condition.RequiredPoints != nil { weeks := a.calculateProjectUnlockWeeks( *condition.ProjectID, *condition.RequiredPoints, startDate, conditionOwnerID, ) weeksText := formatWeeksText(weeks) condition.WeeksText = &weeksText } } item.UnlockConditions = append(item.UnlockConditions, condition) } } var updatedItem *WishlistItem for _, it := range itemsMap { if it.ID == itemID { updatedItem = it break } } if updatedItem == nil { log.Printf("Updated item not found: id=%d", itemID) sendErrorWithCORS(w, "Updated item not found", http.StatusInternalServerError) return } // Проверяем разблокировку updatedItem.Unlocked = true if len(updatedItem.UnlockConditions) > 0 { for _, cond := range updatedItem.UnlockConditions { if cond.Type == "task_completion" { if cond.TaskCompleted == nil || !*cond.TaskCompleted { updatedItem.Unlocked = false break } } else if cond.Type == "project_points" { if cond.CurrentPoints == nil || cond.RequiredPoints == nil || *cond.CurrentPoints < *cond.RequiredPoints { updatedItem.Unlocked = false break } } } } unlocked, err := a.checkWishlistUnlock(itemID, userID) if err == nil { updatedItem.Unlocked = unlocked } // Сортируем условия в нужном порядке a.sortUnlockConditions(updatedItem.UnlockConditions, userID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(updatedItem) } // deleteWishlistHandler удаляет желание (soft delete) func (a *App) deleteWishlistHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) return } // Проверяем доступ к желанию hasAccess, _, _, err := a.checkWishlistAccess(itemID, userID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking wishlist access: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError) return } if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } _, err = a.DB.Exec(` UPDATE wishlist_items SET deleted = TRUE, updated_at = NOW() WHERE id = $1 `, itemID) if err != nil { log.Printf("Error deleting wishlist item: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error deleting wishlist item: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Wishlist item deleted successfully", }) } // uploadWishlistImageHandler загружает картинку для желания func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) wishlistID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) return } // Проверяем доступ к желанию hasAccess, _, _, err := a.checkWishlistAccess(wishlistID, userID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking wishlist access: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError) return } if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } // Парсим multipart form (макс 5MB) err = r.ParseMultipartForm(5 << 20) if err != nil { sendErrorWithCORS(w, "File too large (max 5MB)", http.StatusBadRequest) return } file, _, err := r.FormFile("image") if err != nil { sendErrorWithCORS(w, "Error retrieving file", http.StatusBadRequest) return } defer file.Close() // Декодируем изображение img, err := imaging.Decode(file) if err != nil { sendErrorWithCORS(w, "Invalid image format", http.StatusBadRequest) return } // Сжимаем до максимальной ширины 1200px (сохраняя пропорции) if img.Bounds().Dx() > 1200 { img = imaging.Resize(img, 1200, 0, imaging.Lanczos) } // Создаём директорию uploadDir := fmt.Sprintf("/app/uploads/wishlist/%d", userID) err = os.MkdirAll(uploadDir, 0755) if err != nil { log.Printf("Error creating directory: %v", err) sendErrorWithCORS(w, "Error creating directory", http.StatusInternalServerError) return } // Генерируем уникальное имя файла randomBytes := make([]byte, 8) rand.Read(randomBytes) filename := fmt.Sprintf("%d_%x.jpg", wishlistID, randomBytes) filepath := filepath.Join(uploadDir, filename) dst, err := os.Create(filepath) if err != nil { log.Printf("Error creating file: %v", err) sendErrorWithCORS(w, "Error saving file", http.StatusInternalServerError) return } defer dst.Close() // Кодируем в JPEG с качеством 85% err = jpeg.Encode(dst, img, &jpeg.Options{Quality: 85}) if err != nil { log.Printf("Error encoding image: %v", err) sendErrorWithCORS(w, "Error encoding image", http.StatusInternalServerError) return } // Обновляем путь в БД (уникальное имя файла уже обеспечивает сброс кэша) imagePath := fmt.Sprintf("/uploads/wishlist/%d/%s", userID, filename) _, err = a.DB.Exec(` UPDATE wishlist_items SET image_path = $1, updated_at = NOW() WHERE id = $2 `, imagePath, wishlistID) if err != nil { log.Printf("Error updating database: %v", err) sendErrorWithCORS(w, "Error updating database", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "image_url": imagePath, }) } // deleteWishlistImageHandler удаляет картинку желания func (a *App) deleteWishlistImageHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) wishlistID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) return } // Проверяем доступ к желанию hasAccess, _, _, err := a.checkWishlistAccess(wishlistID, userID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking wishlist access: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError) return } if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } // Получаем текущий путь к изображению из БД var currentImagePath sql.NullString err = a.DB.QueryRow(` SELECT image_path FROM wishlist_items WHERE id = $1 `, wishlistID).Scan(¤tImagePath) if err != nil { log.Printf("Error getting image path: %v", err) sendErrorWithCORS(w, "Error getting image path", http.StatusInternalServerError) return } // Удаляем файл, если он существует if currentImagePath.Valid && currentImagePath.String != "" { filePath := filepath.Join("/app", currentImagePath.String) err = os.Remove(filePath) if err != nil && !os.IsNotExist(err) { log.Printf("Error deleting image file: %v", err) // Продолжаем выполнение даже если файл не найден } } // Обновляем БД, устанавливая image_path в NULL _, err = a.DB.Exec(` UPDATE wishlist_items SET image_path = NULL, updated_at = NOW() WHERE id = $1 `, wishlistID) if err != nil { log.Printf("Error updating database: %v", err) sendErrorWithCORS(w, "Error updating database", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Image deleted successfully", }) } // completeWishlistHandler помечает желание как завершённое func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) return } // Проверяем доступ к желанию hasAccess, _, _, err := a.checkWishlistAccess(itemID, userID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking wishlist access: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError) return } if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } _, err = a.DB.Exec(` UPDATE wishlist_items SET completed = TRUE, updated_at = NOW() WHERE id = $1 `, itemID) if err != nil { log.Printf("Error completing wishlist item: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error completing wishlist item: %v", err), http.StatusInternalServerError) return } // Находим задачу пользователя для этого желания, чтобы исключить её из обработки // (так же, как при закрытии через задачу) var userTaskID int err = a.DB.QueryRow(` SELECT id FROM tasks WHERE wishlist_id = $1 AND user_id = $2 AND deleted = FALSE LIMIT 1 `, itemID, userID).Scan(&userTaskID) // Если задача не найдена, используем 0 (не будет исключена, но это нормально, если задачи нет) if err == sql.ErrNoRows { userTaskID = 0 } else if err != nil { log.Printf("Error finding user task for wishlist item %d: %v", itemID, err) userTaskID = 0 } // Обрабатываем политику награждения для всех задач, связанных с этим желанием // Исключаем задачу пользователя, который закрыл желание (если она есть) a.processWishlistRewardPolicy(itemID, userTaskID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Wishlist item completed successfully", }) } // processWishlistRewardPolicy обрабатывает политику награждения для всех задач, связанных с желанием // completedTaskID - ID задачи, которая была закрыта (исключается из обработки). Если 0, задача не найдена, но это нормально func (a *App) processWishlistRewardPolicy(wishlistItemID int, completedTaskID int) { var rows *sql.Rows var err error if completedTaskID == 0 { // Если задача не найдена (желание закрывается напрямую, но у пользователя нет задачи), // обрабатываем все задачи rows, err = a.DB.Query(` SELECT id, user_id, reward_policy FROM tasks WHERE wishlist_id = $1 AND deleted = FALSE `, wishlistItemID) } else { // Исключаем задачу, которая была закрыта (через задачу или найдена при прямом закрытии желания) rows, err = a.DB.Query(` SELECT id, user_id, reward_policy FROM tasks WHERE wishlist_id = $1 AND deleted = FALSE AND id != $2 `, wishlistItemID, completedTaskID) } if err != nil { log.Printf("Error querying tasks for wishlist item %d: %v", wishlistItemID, err) return } defer rows.Close() for rows.Next() { var taskID, taskUserID int var rewardPolicy sql.NullString err := rows.Scan(&taskID, &taskUserID, &rewardPolicy) if err != nil { log.Printf("Error scanning task: %v", err) continue } policy := "personal" // Значение по умолчанию if rewardPolicy.Valid { policy = rewardPolicy.String } if policy == "personal" { // Личная политика: при закрытии задачи-желания другим пользователем, личная задача удаляется _, err = a.DB.Exec(` UPDATE tasks SET deleted = TRUE WHERE id = $1 `, taskID) if err != nil { log.Printf("Error deleting task %d: %v", taskID, err) } else { log.Printf("Task %d deleted because wishlist item %d was completed by another user (personal policy)", taskID, wishlistItemID) } } else if policy == "general" { // Общая политика: при закрытии задачи-желания другим пользователем, общая задача закрывается _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW() WHERE id = $1 `, taskID) if err != nil { log.Printf("Error completing task %d: %v", taskID, err) } else { log.Printf("Task %d completed automatically after wishlist item %d completion (general policy)", taskID, wishlistItemID) } } } } // uncompleteWishlistHandler снимает отметку завершения func (a *App) uncompleteWishlistHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) return } // Проверяем доступ к желанию hasAccess, _, _, err := a.checkWishlistAccess(itemID, userID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking wishlist access: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError) return } if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } _, err = a.DB.Exec(` UPDATE wishlist_items SET completed = FALSE, rejected = FALSE, updated_at = NOW() WHERE id = $1 `, itemID) if err != nil { log.Printf("Error uncompleting wishlist item: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error uncompleting wishlist item: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Wishlist item uncompleted successfully", }) } // rejectWishlistHandler отклоняет желание (completed=true, rejected=true) func (a *App) rejectWishlistHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) return } // Проверяем доступ к желанию hasAccess, _, _, err := a.checkWishlistAccess(itemID, userID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error checking wishlist access: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError) return } if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } _, err = a.DB.Exec(` UPDATE wishlist_items SET completed = TRUE, rejected = TRUE, updated_at = NOW() WHERE id = $1 `, itemID) if err != nil { log.Printf("Error rejecting wishlist item: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error rejecting wishlist item: %v", err), http.StatusInternalServerError) return } // При отклонении желания удаляем все связанные с ним задачи result, err := a.DB.Exec(` UPDATE tasks SET deleted = TRUE WHERE wishlist_id = $1 AND deleted = FALSE `, itemID) if err != nil { log.Printf("Error deleting tasks for rejected wishlist item %d: %v", itemID, err) } else { rowsAffected, _ := result.RowsAffected() log.Printf("Rejected wishlist item %d: deleted %d tasks", itemID, rowsAffected) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Wishlist item rejected successfully", }) } // copyWishlistHandler копирует желание func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest) return } // Получаем оригинальное желание var name string var price sql.NullFloat64 var link sql.NullString var imagePath sql.NullString var ownerID int var boardID sql.NullInt64 var authorID sql.NullInt64 var groupName sql.NullString err = a.DB.QueryRow(` SELECT user_id, name, price, link, image_path, board_id, author_id, group_name FROM wishlist_items WHERE id = $1 AND deleted = FALSE `, itemID).Scan(&ownerID, &name, &price, &link, &imagePath, &boardID, &authorID, &groupName) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error getting wishlist item: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist item: %v", err), http.StatusInternalServerError) return } // Получаем условия оригинального желания rows, err := a.DB.Query(` SELECT wc.display_order, wc.task_condition_id, wc.score_condition_id, tc.task_id, sc.project_id, sc.required_points, sc.start_date FROM wishlist_conditions wc LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id WHERE wc.wishlist_item_id = $1 ORDER BY wc.display_order `, itemID) if err != nil { log.Printf("Error getting wishlist conditions: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist conditions: %v", err), http.StatusInternalServerError) return } defer rows.Close() var conditions []UnlockConditionRequest for rows.Next() { var displayOrder int var taskConditionID, scoreConditionID sql.NullInt64 var taskID, projectID sql.NullInt64 var requiredPoints sql.NullFloat64 var startDate sql.NullString err := rows.Scan(&displayOrder, &taskConditionID, &scoreConditionID, &taskID, &projectID, &requiredPoints, &startDate) if err != nil { log.Printf("Error scanning condition row: %v", err) continue } cond := UnlockConditionRequest{ DisplayOrder: &displayOrder, } if taskConditionID.Valid && taskID.Valid { cond.Type = "task_completion" tid := int(taskID.Int64) cond.TaskID = &tid } else if scoreConditionID.Valid && projectID.Valid { cond.Type = "project_points" pid := int(projectID.Int64) cond.ProjectID = &pid if requiredPoints.Valid { cond.RequiredPoints = &requiredPoints.Float64 } if startDate.Valid { cond.StartDate = &startDate.String } } conditions = append(conditions, cond) } // Создаём копию в транзакции 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 newWishlistID int var priceVal, linkVal interface{} if price.Valid { priceVal = price.Float64 } if link.Valid { linkVal = link.String } // Определяем значения для board_id и author_id var boardIDVal, authorIDVal, groupNameVal interface{} if boardID.Valid { boardIDVal = int(boardID.Int64) } if authorID.Valid { authorIDVal = int(authorID.Int64) } else { // Если author_id не был установлен, используем текущего пользователя authorIDVal = userID } if groupName.Valid { groupNameVal = groupName.String } err = tx.QueryRow(` INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, group_name, completed, deleted) VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, FALSE) RETURNING id `, ownerID, boardIDVal, authorIDVal, name+" (копия)", priceVal, linkVal, groupNameVal).Scan(&newWishlistID) if err != nil { log.Printf("Error creating wishlist copy: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist copy: %v", err), http.StatusInternalServerError) return } // Сохраняем условия if len(conditions) > 0 { err = a.saveWishlistConditions(tx, newWishlistID, userID, conditions) if err != nil { log.Printf("Error saving wishlist conditions: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError) return } } // Копируем изображение, если есть 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 { // Создаём директорию для нового желания newImageDir := filepath.Join(uploadsDir, "wishlist", strconv.Itoa(userID)) 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 { log.Printf("Error opening source file: %v", openErr) } else { defer srcFile.Close() dstFile, createErr := os.Create(newImagePath) if createErr != nil { log.Printf("Error creating dest file: %v", createErr) } else { defer dstFile.Close() _, copyErr := io.Copy(dstFile, srcFile) if copyErr != nil { log.Printf("Error copying file: %v", copyErr) } else { // Обновляем путь к изображению в БД (с /uploads/ в начале для совместимости) relativePath := "/uploads/" + filepath.Join("wishlist", strconv.Itoa(userID), newFileName) log.Printf("Updating image_path in DB to: %s", relativePath) _, updateErr := tx.Exec(`UPDATE wishlist_items SET image_path = $1 WHERE id = $2`, relativePath, newWishlistID) if updateErr != nil { log.Printf("Error updating image_path in DB: %v", updateErr) } } } } } else { log.Printf("Original image file not found: %s, error: %v", originalPath, statErr) } } else { log.Printf("No image to copy: imagePath.Valid=%v, imagePath.String=%s", imagePath.Valid, imagePath.String) } 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 } // Обновляем MV для групповых саджестов if groupName.Valid && groupName.String != "" { if err := a.refreshGroupSuggestionsMV(); err != nil { log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) } } // Получаем созданное желание с условиями items, err := a.getWishlistItemsWithConditions(userID, false) if err != nil { log.Printf("Error getting created wishlist item: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error getting created wishlist item: %v", err), http.StatusInternalServerError) return } var createdItem *WishlistItem for i := range items { if items[i].ID == newWishlistID { createdItem = &items[i] break } } if createdItem == nil { sendErrorWithCORS(w, "Created item not found", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(createdItem) } // ============================================ // Wishlist Boards handlers // ============================================ // generateInviteToken генерирует уникальный токен для приглашения func generateInviteToken() string { b := make([]byte, 32) rand.Read(b) return hex.EncodeToString(b) } // getBoardsHandler возвращает список досок пользователя (свои + присоединённые) func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } boards := []WishlistBoard{} // Получаем свои доски + доски где пользователь участник rows, err := a.DB.Query(` SELECT DISTINCT wb.id, wb.owner_id, COALESCE(u.name, u.email) as owner_name, wb.name, wb.invite_enabled, wb.invite_token, wb.created_at, (SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count, (wb.owner_id = $1) as is_owner FROM wishlist_boards wb JOIN users u ON wb.owner_id = u.id LEFT JOIN wishlist_board_members wbm ON wb.id = wbm.board_id WHERE wb.deleted = FALSE AND (wb.owner_id = $1 OR wbm.user_id = $1) ORDER BY is_owner DESC, wb.created_at DESC `, userID) if err != nil { log.Printf("Error getting boards: %v", err) sendErrorWithCORS(w, "Error getting boards", http.StatusInternalServerError) return } defer rows.Close() baseURL := getEnv("WEBHOOK_BASE_URL", "") for rows.Next() { var board WishlistBoard var inviteToken sql.NullString err := rows.Scan( &board.ID, &board.OwnerID, &board.OwnerName, &board.Name, &board.InviteEnabled, &inviteToken, &board.CreatedAt, &board.MemberCount, &board.IsOwner, ) if err != nil { log.Printf("Error scanning board: %v", err) continue } // Invite token и URL только для владельца if board.IsOwner && inviteToken.Valid { board.InviteToken = &inviteToken.String if baseURL != "" { url := baseURL + "/invite/" + inviteToken.String board.InviteURL = &url } } boards = append(boards, board) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(boards) } // createBoardHandler создаёт новую доску func (a *App) createBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req BoardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if strings.TrimSpace(req.Name) == "" { sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) return } var boardID int err := a.DB.QueryRow(` INSERT INTO wishlist_boards (owner_id, name) VALUES ($1, $2) RETURNING id `, userID, strings.TrimSpace(req.Name)).Scan(&boardID) if err != nil { log.Printf("Error creating board: %v", err) sendErrorWithCORS(w, "Error creating board", http.StatusInternalServerError) return } // Возвращаем созданную доску board := WishlistBoard{ ID: boardID, OwnerID: userID, Name: strings.TrimSpace(req.Name), InviteEnabled: false, MemberCount: 0, IsOwner: true, CreatedAt: time.Now(), } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(board) } // getBoardHandler возвращает детали доски func (a *App) getBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } var board WishlistBoard var inviteToken sql.NullString err = a.DB.QueryRow(` SELECT wb.id, wb.owner_id, COALESCE(u.name, u.email) as owner_name, wb.name, wb.invite_enabled, wb.invite_token, wb.created_at, (SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count FROM wishlist_boards wb JOIN users u ON wb.owner_id = u.id WHERE wb.id = $1 AND wb.deleted = FALSE `, boardID).Scan( &board.ID, &board.OwnerID, &board.OwnerName, &board.Name, &board.InviteEnabled, &inviteToken, &board.CreatedAt, &board.MemberCount, ) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if err != nil { log.Printf("Error getting board: %v", err) sendErrorWithCORS(w, "Error getting board", http.StatusInternalServerError) return } board.IsOwner = board.OwnerID == userID // Проверяем доступ (владелец или участник) if !board.IsOwner { var isMember bool 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 } } // Invite token и URL только для владельца if board.IsOwner && inviteToken.Valid { board.InviteToken = &inviteToken.String baseURL := getEnv("WEBHOOK_BASE_URL", "") if baseURL != "" { url := baseURL + "/invite/" + inviteToken.String board.InviteURL = &url } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(board) } // updateBoardHandler обновляет доску (только владелец) func (a *App) updateBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } // Проверяем что пользователь - владелец var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { sendErrorWithCORS(w, "Only owner can update board", http.StatusForbidden) return } var req BoardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Обновляем поля if strings.TrimSpace(req.Name) != "" { _, 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) } } if req.InviteEnabled != nil { // Если включаем приглашения и нет токена - генерируем 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`, token, boardID) } else { _, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_enabled = TRUE, updated_at = NOW() WHERE id = $1`, boardID) } } else { _, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_enabled = FALSE, updated_at = NOW() WHERE id = $1`, boardID) } if err != nil { log.Printf("Error updating board invite_enabled: %v", err) } } // Возвращаем обновлённую доску var board WishlistBoard var inviteToken sql.NullString a.DB.QueryRow(` SELECT wb.id, wb.owner_id, COALESCE(u.name, u.email), wb.name, wb.invite_enabled, wb.invite_token, wb.created_at, (SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) FROM wishlist_boards wb JOIN users u ON wb.owner_id = u.id WHERE wb.id = $1 `, boardID).Scan(&board.ID, &board.OwnerID, &board.OwnerName, &board.Name, &board.InviteEnabled, &inviteToken, &board.CreatedAt, &board.MemberCount) board.IsOwner = true if inviteToken.Valid { board.InviteToken = &inviteToken.String baseURL := getEnv("WEBHOOK_BASE_URL", "") if baseURL != "" { url := baseURL + "/invite/" + inviteToken.String board.InviteURL = &url } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(board) } // deleteBoardHandler удаляет доску (только владелец) func (a *App) deleteBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } // Проверяем что пользователь - владелец var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { sendErrorWithCORS(w, "Only owner can delete board", http.StatusForbidden) return } // Soft delete доски и всех её желаний _, err = a.DB.Exec(`UPDATE wishlist_boards SET deleted = TRUE, updated_at = NOW() WHERE id = $1`, boardID) if err != nil { log.Printf("Error deleting board: %v", err) sendErrorWithCORS(w, "Error deleting board", http.StatusInternalServerError) return } // Soft delete всех желаний на доске _, err = a.DB.Exec(`UPDATE wishlist_items SET deleted = TRUE, updated_at = NOW() WHERE board_id = $1`, boardID) if err != nil { log.Printf("Error deleting board items: %v", err) } w.WriteHeader(http.StatusNoContent) } // regenerateBoardInviteHandler перегенерирует invite token func (a *App) regenerateBoardInviteHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } // Проверяем что пользователь - владелец var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { sendErrorWithCORS(w, "Only owner can regenerate invite", http.StatusForbidden) return } token := generateInviteToken() _, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_token = $1, invite_enabled = TRUE, updated_at = NOW() WHERE id = $2`, token, boardID) if err != nil { log.Printf("Error regenerating invite token: %v", err) sendErrorWithCORS(w, "Error regenerating invite", http.StatusInternalServerError) return } baseURL := getEnv("WEBHOOK_BASE_URL", "") inviteURL := "" if baseURL != "" { inviteURL = baseURL + "/invite/" + token } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "invite_token": token, "invite_url": inviteURL, }) } // getBoardMembersHandler возвращает список участников доски func (a *App) getBoardMembersHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } // Проверяем что пользователь - владелец var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { sendErrorWithCORS(w, "Only owner can view members", http.StatusForbidden) return } members := []BoardMember{} rows, err := a.DB.Query(` SELECT wbm.id, wbm.user_id, COALESCE(u.name, '') as name, u.email, wbm.joined_at FROM wishlist_board_members wbm JOIN users u ON wbm.user_id = u.id WHERE wbm.board_id = $1 ORDER BY wbm.joined_at DESC `, boardID) if err != nil { log.Printf("Error getting members: %v", err) sendErrorWithCORS(w, "Error getting members", http.StatusInternalServerError) return } defer rows.Close() for rows.Next() { var member BoardMember err := rows.Scan(&member.ID, &member.UserID, &member.Name, &member.Email, &member.JoinedAt) if err != nil { log.Printf("Error scanning member: %v", err) continue } members = append(members, member) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(members) } // removeBoardMemberHandler удаляет участника из доски func (a *App) removeBoardMemberHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } memberUserID, err := strconv.Atoi(vars["userId"]) if err != nil { sendErrorWithCORS(w, "Invalid user ID", http.StatusBadRequest) return } // Проверяем что пользователь - владелец var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { sendErrorWithCORS(w, "Only owner can remove members", http.StatusForbidden) return } _, err = a.DB.Exec(`DELETE FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2`, boardID, memberUserID) if err != nil { log.Printf("Error removing member: %v", err) sendErrorWithCORS(w, "Error removing member", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // leaveBoardHandler позволяет участнику выйти из доски func (a *App) leaveBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } // Проверяем что пользователь НЕ владелец var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID == userID { sendErrorWithCORS(w, "Owner cannot leave board, delete it instead", http.StatusBadRequest) return } _, err = a.DB.Exec(`DELETE FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID) if err != nil { log.Printf("Error leaving board: %v", err) sendErrorWithCORS(w, "Error leaving board", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // getBoardInviteInfoHandler возвращает информацию о доске по invite token func (a *App) getBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) vars := mux.Vars(r) token := vars["token"] var info BoardInviteInfo var ownerName string err := a.DB.QueryRow(` SELECT wb.id, wb.name, COALESCE(u.name, u.email) as owner_name, (SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count FROM wishlist_boards wb JOIN users u ON wb.owner_id = u.id WHERE wb.invite_token = $1 AND wb.invite_enabled = TRUE AND wb.deleted = FALSE `, token).Scan(&info.BoardID, &info.Name, &ownerName, &info.MemberCount) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound) return } if err != nil { log.Printf("Error getting invite info: %v", err) sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) return } info.OwnerName = ownerName w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(info) } // joinBoardHandler присоединяет пользователя к доске по invite token func (a *App) joinBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) token := vars["token"] // Получаем доску по токену var boardID, ownerID int var boardName, ownerName string err := a.DB.QueryRow(` SELECT wb.id, wb.owner_id, wb.name, COALESCE(u.name, u.email) FROM wishlist_boards wb JOIN users u ON wb.owner_id = u.id WHERE wb.invite_token = $1 AND wb.invite_enabled = TRUE AND wb.deleted = FALSE `, token).Scan(&boardID, &ownerID, &boardName, &ownerName) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound) return } if err != nil { log.Printf("Error getting board by token: %v", err) sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError) return } // Проверяем что пользователь не владелец if ownerID == userID { sendErrorWithCORS(w, "You are the owner of this board", http.StatusBadRequest) return } // Проверяем что пользователь ещё не участник var exists bool 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) return } // Добавляем пользователя как участника _, err = a.DB.Exec(`INSERT INTO wishlist_board_members (board_id, user_id) VALUES ($1, $2)`, boardID, userID) if err != nil { log.Printf("Error joining board: %v", err) sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError) return } // Получаем количество участников var memberCount int a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_board_members WHERE board_id = $1`, boardID).Scan(&memberCount) board := WishlistBoard{ ID: boardID, OwnerID: ownerID, OwnerName: ownerName, Name: boardName, InviteEnabled: true, MemberCount: memberCount, IsOwner: false, } response := JoinBoardResponse{ Board: board, Message: "Вы успешно присоединились к доске!", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(response) } // getBoardItemsHandler возвращает желания на доске func (a *App) getBoardItemsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["boardId"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } // Проверяем доступ к доске (владелец или участник) var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } hasAccess := ownerID == userID if !hasAccess { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) hasAccess = isMember } if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } // Получаем желания на доске (используем существующую логику, но фильтруем по board_id) items, err := a.getWishlistItemsByBoard(boardID, userID) if err != nil { log.Printf("Error getting board items: %v", err) sendErrorWithCORS(w, "Error getting items", http.StatusInternalServerError) return } // Разделяем на unlocked/locked unlocked := []WishlistItem{} locked := []WishlistItem{} for _, item := range items { if item.Unlocked { unlocked = append(unlocked, item) } else { locked = append(locked, item) } } // Сортируем разблокированные по цене от меньшего к большему sort.Slice(unlocked, func(i, j int) bool { priceI := 0.0 priceJ := 0.0 if unlocked[i].Price != nil { priceI = *unlocked[i].Price } if unlocked[j].Price != nil { priceJ = *unlocked[j].Price } if priceI == priceJ { return unlocked[i].ID < unlocked[j].ID } return priceI < priceJ }) // Разделяем заблокированные на группы (с задачами и без задач) lockedWithoutTasks := []WishlistItem{} lockedWithTasks := []WishlistItem{} for _, item := range locked { hasUncompletedTasks := false for _, cond := range item.UnlockConditions { if cond.Type == "task_completion" && (cond.TaskCompleted == nil || !*cond.TaskCompleted) { hasUncompletedTasks = true break } } if hasUncompletedTasks { lockedWithTasks = append(lockedWithTasks, item) } else { lockedWithoutTasks = append(lockedWithoutTasks, item) } } // Сортируем каждую группу по времени разблокировки (от меньшего срока к большему) sort.Slice(lockedWithoutTasks, func(i, j int) bool { valueI := a.calculateLockedSortValue(lockedWithoutTasks[i], userID) valueJ := a.calculateLockedSortValue(lockedWithoutTasks[j], userID) if valueI == valueJ { return lockedWithoutTasks[i].ID < lockedWithoutTasks[j].ID } return valueI < valueJ }) sort.Slice(lockedWithTasks, func(i, j int) bool { valueI := a.calculateLockedSortValue(lockedWithTasks[i], userID) valueJ := a.calculateLockedSortValue(lockedWithTasks[j], userID) if valueI == valueJ { return lockedWithTasks[i].ID < lockedWithTasks[j].ID } return valueI < valueJ }) // Объединяем: сначала без задач, потом с задачами locked = append(lockedWithoutTasks, lockedWithTasks...) // Считаем завершённые var completedCount int a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_items WHERE board_id = $1 AND completed = TRUE AND deleted = FALSE`, boardID).Scan(&completedCount) response := WishlistResponse{ Unlocked: unlocked, Locked: locked, CompletedCount: completedCount, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // getBoardCompletedHandler возвращает завершённые желания на доске func (a *App) getBoardCompletedHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["boardId"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } // Проверяем доступ к доске (владелец или участник) var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } hasAccess := ownerID == userID if !hasAccess { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) hasAccess = isMember } if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } // Получаем завершённые желания на доске (отдельный запрос, так как getWishlistItemsByBoard исключает завершённые) query := ` SELECT wi.id, wi.name, wi.price, wi.image_path, wi.link, wi.completed, wi.rejected, wi.group_name AS item_group_name, wc.id AS condition_id, wc.display_order, wc.task_condition_id, wc.score_condition_id, wc.user_id, tc.task_id, t.name AS task_name, sc.project_id, p.name AS project_name, sc.required_points, sc.start_date, COALESCE(u.name, u.email) AS user_name FROM wishlist_items wi LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE LEFT JOIN users u ON wc.user_id = u.id WHERE wi.board_id = $1 AND wi.deleted = FALSE AND wi.completed = TRUE ORDER BY wi.id, wc.display_order, wc.id ` rows, err := a.DB.Query(query, boardID) if err != nil { log.Printf("Error executing query for board completed items (boardID=%d): %v", boardID, err) sendErrorWithCORS(w, fmt.Sprintf("Error getting completed items: %v", err), http.StatusInternalServerError) return } defer rows.Close() itemsMap := make(map[int]*WishlistItem) for rows.Next() { var itemID int var name string var price sql.NullFloat64 var imagePath sql.NullString var link sql.NullString var completed bool var rejected bool var itemGroupName sql.NullString var conditionID sql.NullInt64 var displayOrder sql.NullInt64 var taskConditionID sql.NullInt64 var scoreConditionID sql.NullInt64 var userIDCond sql.NullInt64 var taskID sql.NullInt64 var taskName sql.NullString var projectID sql.NullInt64 var projectName sql.NullString var requiredPoints sql.NullFloat64 var startDate sql.NullTime var userName sql.NullString err := rows.Scan( &itemID, &name, &price, &imagePath, &link, &completed, &rejected, &itemGroupName, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &userIDCond, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, &userName, ) if err != nil { log.Printf("Error scanning completed wishlist item: %v", err) continue } item, exists := itemsMap[itemID] if !exists { item = &WishlistItem{ ID: itemID, Name: name, Completed: completed, Rejected: rejected, UnlockConditions: []UnlockConditionDisplay{}, } if price.Valid { item.Price = &price.Float64 } if imagePath.Valid && imagePath.String != "" { url := imagePath.String if !strings.HasPrefix(url, "http") { url = url + "?t=" + strconv.FormatInt(time.Now().Unix(), 10) } item.ImageURL = &url } if link.Valid { item.Link = &link.String } if itemGroupName.Valid && itemGroupName.String != "" { item.GroupName = &itemGroupName.String } itemsMap[itemID] = item } if conditionID.Valid { // Определяем владельца условия conditionOwnerID := userID if userIDCond.Valid { conditionOwnerID = int(userIDCond.Int64) } // Если это условие по задаче, проверяем существует ли задача if taskConditionID.Valid && taskID.Valid { // Проверяем, существует ли задача (не удалена) var taskExists bool err := a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)`, taskID.Int64, conditionOwnerID).Scan(&taskExists) if err != nil || !taskExists { // Задача удалена - не добавляем условие в список, но при проверке блокировки оно считается выполненным continue } } condition := UnlockConditionDisplay{ ID: int(conditionID.Int64), DisplayOrder: int(displayOrder.Int64), } if taskConditionID.Valid { condition.Type = "task_completion" if taskID.Valid { taskIDVal := int(taskID.Int64) condition.TaskID = &taskIDVal if taskName.Valid { condition.TaskName = &taskName.String } } } else if scoreConditionID.Valid { condition.Type = "project_points" if projectID.Valid { projectIDVal := int(projectID.Int64) condition.ProjectID = &projectIDVal if projectName.Valid { condition.ProjectName = &projectName.String } if requiredPoints.Valid { condition.RequiredPoints = &requiredPoints.Float64 } if startDate.Valid { dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } } } if userIDCond.Valid { userIDVal := int(userIDCond.Int64) condition.UserID = &userIDVal if userName.Valid { condition.UserName = &userName.String } } item.UnlockConditions = append(item.UnlockConditions, condition) } } if err := rows.Err(); err != nil { log.Printf("Error iterating rows for board completed items (boardID=%d): %v", boardID, err) sendErrorWithCORS(w, fmt.Sprintf("Error getting completed items: %v", err), http.StatusInternalServerError) return } // Преобразуем map в slice completed := make([]WishlistItem, 0, len(itemsMap)) for _, item := range itemsMap { completed = append(completed, *item) } // Сортируем по цене (дорогие → дешёвые) sort.Slice(completed, func(i, j int) bool { priceI := 0.0 priceJ := 0.0 if completed[i].Price != nil { priceI = *completed[i].Price } if completed[j].Price != nil { priceJ = *completed[j].Price } return priceI > priceJ }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(completed) } // calculateUnlockedSortValue считает сумму баллов, которые были нужны для разблокировки // Задача считается как 1 балл, project_points как required_points func calculateUnlockedSortValue(item WishlistItem) float64 { var totalRequired float64 = 0.0 for _, condition := range item.UnlockConditions { if condition.Type == "task_completion" { totalRequired += 1.0 } else if condition.Type == "project_points" { if condition.RequiredPoints != nil { totalRequired += *condition.RequiredPoints } } } return totalRequired } // calculateLockedSortValue считает сумму оставшихся баллов для разблокировки // Задача считается как 1 балл (если не выполнена), project_points как remaining баллы func (a *App) calculateLockedSortValue(item WishlistItem, userID int) float64 { // Если нет условий, возвращаем большое значение (отсутствие условий = все выполнены) if len(item.UnlockConditions) == 0 { return 999999.0 } maxWeeks := 0.0 hasProjectConditions := false allCompleted := true for _, condition := range item.UnlockConditions { if condition.Type == "project_points" { hasProjectConditions = true if condition.RequiredPoints != nil { var startDate sql.NullTime if condition.StartDate != nil { date, err := time.Parse("2006-01-02", *condition.StartDate) if err == nil { startDate = sql.NullTime{Time: date, Valid: true} } } // ВАЖНО: Используем владельца условия из condition.UserID // Если condition.UserID есть - это владелец условия // Если нет - получаем владельца желания из БД (для старых условий) // НЕ используем текущего пользователя (userID), так как условие может принадлежать другому пользователю conditionOwnerID := 0 if condition.UserID != nil { conditionOwnerID = *condition.UserID } else { // Если нет владельца условия, получаем владельца желания из БД var itemOwnerID int err := a.DB.QueryRow(`SELECT user_id FROM wishlist_items WHERE id = $1`, item.ID).Scan(&itemOwnerID) if err != nil { log.Printf("Error getting wishlist item owner for item %d: %v", item.ID, err) continue // Пропускаем условие, если не можем получить владельца } conditionOwnerID = itemOwnerID } // Получаем projectID из условия if condition.ProjectID != nil { weeks := a.calculateProjectUnlockWeeks( *condition.ProjectID, *condition.RequiredPoints, startDate, conditionOwnerID, // Владелец условия, а не текущий пользователь ) // weeks > 0 && < 99999 означает, что условие еще не выполнено и расчет успешен // weeks == 0 означает условие выполнено // weeks == 99999 означает медиана отсутствует (нельзя рассчитать) или ошибка расчета if weeks == 0 { // Условие выполнено - считаем как 0 недель // Не обновляем maxWeeks, так как 0 < любого положительного значения } else if weeks > 0 && weeks < 99999 { // Условие не выполнено - учитываем в maxWeeks allCompleted = false if weeks > maxWeeks { maxWeeks = weeks } } else { // weeks == 99999 - нельзя рассчитать, считаем как невыполненное allCompleted = false } } } } } // Если были условия по проектам и все выполнены, возвращаем 0 (закрытые испытания = 0 недель) if hasProjectConditions && allCompleted { return 0.0 } // Если не было условий по проектам (только задачи или нет условий) if !hasProjectConditions { return 999999.0 } return maxWeeks } // getWishlistItemsByBoard загружает желания конкретной доски func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, error) { query := ` SELECT wi.id, wi.name, wi.price, wi.image_path, wi.link, wi.completed, wi.group_name, COALESCE(wi.author_id, wi.user_id) AS item_owner_id, wc.id AS condition_id, wc.display_order, wc.task_condition_id, wc.score_condition_id, wc.user_id AS condition_user_id, tc.task_id, t.name AS task_name, sc.project_id, p.name AS project_name, sc.required_points, sc.start_date FROM wishlist_items wi LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE WHERE wi.board_id = $1 AND wi.deleted = FALSE AND wi.completed = FALSE ORDER BY wi.id, wc.display_order, wc.id ` rows, err := a.DB.Query(query, boardID) if err != nil { return nil, err } defer rows.Close() itemsMap := make(map[int]*WishlistItem) for rows.Next() { var itemID int var name string var price sql.NullFloat64 var imagePath sql.NullString var link sql.NullString var completed bool var groupName sql.NullString var itemOwnerID sql.NullInt64 var conditionID sql.NullInt64 var displayOrder sql.NullInt64 var taskConditionID sql.NullInt64 var scoreConditionID sql.NullInt64 var conditionUserID sql.NullInt64 var taskID sql.NullInt64 var taskName sql.NullString var projectID sql.NullInt64 var projectName sql.NullString var requiredPoints sql.NullFloat64 var startDate sql.NullTime err := rows.Scan( &itemID, &name, &price, &imagePath, &link, &completed, &groupName, &itemOwnerID, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, ) if err != nil { log.Printf("Error scanning wishlist item: %v", err) continue } item, exists := itemsMap[itemID] if !exists { item = &WishlistItem{ ID: itemID, Name: name, Completed: completed, UnlockConditions: []UnlockConditionDisplay{}, } if price.Valid { item.Price = &price.Float64 } if imagePath.Valid && imagePath.String != "" { url := imagePath.String if !strings.HasPrefix(url, "http") { url = url + "?t=" + strconv.FormatInt(time.Now().Unix(), 10) } item.ImageURL = &url } if link.Valid { item.Link = &link.String } if groupName.Valid && groupName.String != "" { groupNameVal := groupName.String item.GroupName = &groupNameVal } itemsMap[itemID] = item } if conditionID.Valid { // Используем user_id из условия, если он есть, иначе используем владельца желания if !itemOwnerID.Valid { log.Printf("Warning: item_owner_id is NULL for wishlist item %d, skipping condition", itemID) continue } conditionOwnerID := int(itemOwnerID.Int64) if conditionUserID.Valid { conditionOwnerID = int(conditionUserID.Int64) } // Если это условие по задаче, проверяем существует ли задача if taskConditionID.Valid && taskID.Valid { // Проверяем, существует ли задача (не удалена) var taskExists bool err := a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)`, taskID.Int64, conditionOwnerID).Scan(&taskExists) if err != nil || !taskExists { // Задача удалена - не добавляем условие в список, но при проверке блокировки оно считается выполненным continue } } condition := UnlockConditionDisplay{ ID: int(conditionID.Int64), DisplayOrder: int(displayOrder.Int64), } if conditionUserID.Valid { conditionOwnerIDVal := int(conditionUserID.Int64) condition.UserID = &conditionOwnerIDVal } else { itemOwnerIDVal := int(itemOwnerID.Int64) condition.UserID = &itemOwnerIDVal } if taskConditionID.Valid { condition.Type = "task_completion" if taskName.Valid { condition.TaskName = &taskName.String } // Условие выполнено, если задача удалена или не существует if taskID.Valid { taskIDVal := int(taskID.Int64) condition.TaskID = &taskIDVal var taskCompleted int err := a.DB.QueryRow(`SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE`, taskID.Int64, conditionOwnerID).Scan(&taskCompleted) if err == sql.ErrNoRows { // Задача удалена или не существует — условие выполнено t := true condition.TaskCompleted = &t } else if err == nil { // Задача существует и не удалена — условие не выполнено f := false condition.TaskCompleted = &f } } } else if scoreConditionID.Valid { condition.Type = "project_points" if projectName.Valid { condition.ProjectName = &projectName.String } if projectID.Valid { projectIDVal := int(projectID.Int64) condition.ProjectID = &projectIDVal // Считаем текущие баллы для владельца условия points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID) condition.CurrentPoints = &points } if requiredPoints.Valid { condition.RequiredPoints = &requiredPoints.Float64 } if startDate.Valid { dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } // Рассчитываем и форматируем срок разблокировки if condition.ProjectID != nil && condition.RequiredPoints != nil { weeks := a.calculateProjectUnlockWeeks( *condition.ProjectID, *condition.RequiredPoints, startDate, conditionOwnerID, ) weeksText := formatWeeksText(weeks) condition.WeeksText = &weeksText } } item.UnlockConditions = append(item.UnlockConditions, condition) } } // Преобразуем map в slice и определяем unlocked items := make([]WishlistItem, 0, len(itemsMap)) for _, item := range itemsMap { // Сортируем условия в нужном порядке a.sortUnlockConditions(item.UnlockConditions, userID) // Проверяем все условия item.Unlocked = true if len(item.UnlockConditions) > 0 { for _, cond := range item.UnlockConditions { if cond.Type == "task_completion" { if cond.TaskCompleted == nil || !*cond.TaskCompleted { item.Unlocked = false break } } else if cond.Type == "project_points" { if cond.CurrentPoints == nil || cond.RequiredPoints == nil || *cond.CurrentPoints < *cond.RequiredPoints { item.Unlocked = false break } } } } // Определяем первое заблокированное условие и количество остальных if !item.Unlocked && !item.Completed { lockedCount := 0 var firstLocked *UnlockConditionDisplay for i := range item.UnlockConditions { condition := &item.UnlockConditions[i] if isConditionLocked(*condition) { lockedCount++ if firstLocked == nil { firstLocked = condition } } } if firstLocked != nil { item.FirstLockedCondition = firstLocked item.MoreLockedConditions = lockedCount - 1 item.LockedConditionsCount = lockedCount } } // Загружаем связанную задачу текущего пользователя, если есть var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64 var linkedTaskName sql.NullString var linkedTaskNextShowAt sql.NullTime linkedTaskErr := a.DB.QueryRow(` SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id FROM tasks t WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE LIMIT 1 `, item.ID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) if linkedTaskErr == nil && linkedTaskID.Valid { linkedTask := &LinkedTask{ ID: int(linkedTaskID.Int64), Name: linkedTaskName.String, Completed: int(linkedTaskCompleted.Int64), } if linkedTaskNextShowAt.Valid { nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339) linkedTask.NextShowAt = &nextShowAtStr } if linkedTaskUserID.Valid { userIDVal := int(linkedTaskUserID.Int64) linkedTask.UserID = &userIDVal } item.LinkedTask = linkedTask } else if linkedTaskErr != sql.ErrNoRows { log.Printf("Error loading linked task for wishlist %d: %v", item.ID, linkedTaskErr) // Не возвращаем ошибку, просто не устанавливаем linked_task } // Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей) // Исключаем linked_task из подсчета, если она есть // Учитываем только не закрытые задачи (completed = 0) var tasksCount int if linkedTaskID.Valid { // Если есть linked_task, исключаем её из подсчета err = a.DB.QueryRow(` SELECT COUNT(*) FROM tasks t WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2 `, item.ID, linkedTaskID.Int64).Scan(&tasksCount) } else { // Если нет linked_task, считаем все не закрытые задачи err = a.DB.QueryRow(` SELECT COUNT(*) FROM tasks t WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 `, item.ID).Scan(&tasksCount) } if err != nil { log.Printf("Error counting tasks for wishlist %d: %v", item.ID, err) tasksCount = 0 } item.TasksCount = tasksCount items = append(items, *item) } return items, nil } // createBoardItemHandler создаёт желание на доске func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["boardId"]) if err != nil { log.Printf("createBoardItemHandler: Error parsing boardId from URL: %v, vars['boardId']='%s'", err, vars["boardId"]) sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } // Проверяем доступ к доске var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } hasAccess := ownerID == userID if !hasAccess { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) hasAccess = isMember } if !hasAccess { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } var req WishlistRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("createBoardItemHandler: Error decoding wishlist request: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } log.Printf("createBoardItemHandler: 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("createBoardItemHandler: WARNING - UnlockConditions is nil, initializing empty slice") req.UnlockConditions = []UnlockConditionRequest{} } if strings.TrimSpace(req.Name) == "" { log.Printf("createBoardItemHandler: Name is required") sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) return } tx, err := a.DB.Begin() if err != nil { log.Printf("Error starting transaction: %v", err) sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError) return } defer tx.Rollback() var itemID int err = tx.QueryRow(` INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, group_name, completed, deleted) VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, FALSE) RETURNING id `, ownerID, boardID, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.GroupName).Scan(&itemID) if err != nil { log.Printf("createBoardItemHandler: Error creating board item: %v", err) sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError) return } log.Printf("createBoardItemHandler: created wishlist item id=%d", itemID) // Сохраняем условия с user_id текущего пользователя if len(req.UnlockConditions) > 0 { log.Printf("createBoardItemHandler: saving %d conditions", len(req.UnlockConditions)) err = a.saveWishlistConditionsWithUserID(tx, itemID, userID, req.UnlockConditions) if err != nil { log.Printf("createBoardItemHandler: Error saving wishlist conditions: %v", err) sendErrorWithCORS(w, "Error saving conditions", http.StatusInternalServerError) return } log.Printf("createBoardItemHandler: conditions saved successfully") } else { log.Printf("createBoardItemHandler: no conditions to save") } if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError) return } // Обновляем MV для групповых саджестов if req.GroupName != nil && *req.GroupName != "" { if err := a.refreshGroupSuggestionsMV(); err != nil { log.Printf("Warning: Failed to refresh group suggestions MV: %v", err) } } // Возвращаем созданное желание w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]int{"id": itemID}) } // saveWishlistConditionsWithUserID сохраняет условия с указанием user_id func (a *App) saveWishlistConditionsWithUserID(tx *sql.Tx, wishlistItemID int, userID int, conditions []UnlockConditionRequest) error { log.Printf("saveWishlistConditionsWithUserID: wishlistItemID=%d, userID=%d, conditions=%d", wishlistItemID, userID, len(conditions)) for i, cond := range conditions { displayOrder := i if cond.DisplayOrder != nil { displayOrder = *cond.DisplayOrder } log.Printf("saveWishlistConditionsWithUserID: processing condition %d - type='%s', taskID=%v, projectID=%v", i, cond.Type, cond.TaskID, cond.ProjectID) switch cond.Type { case "task_completion": if cond.TaskID == nil { continue } // Создаём task_condition var taskConditionID int err := tx.QueryRow(` INSERT INTO task_conditions (task_id) VALUES ($1) ON CONFLICT (task_id) DO UPDATE SET task_id = EXCLUDED.task_id RETURNING id `, *cond.TaskID).Scan(&taskConditionID) if err != nil { log.Printf("saveWishlistConditionsWithUserID: error creating task condition: %v", err) return fmt.Errorf("error creating task condition: %w", err) } // Связываем с wishlist_item _, err = tx.Exec(` INSERT INTO wishlist_conditions (wishlist_item_id, user_id, task_condition_id, display_order) VALUES ($1, $2, $3, $4) `, wishlistItemID, userID, taskConditionID, displayOrder) if err != nil { log.Printf("saveWishlistConditionsWithUserID: error linking task condition: %v", err) return fmt.Errorf("error linking task condition: %w", err) } case "project_points": if cond.ProjectID == nil || cond.RequiredPoints == nil { continue } // Создаём score_condition var scoreConditionID int var startDateVal interface{} = nil if cond.StartDate != nil && *cond.StartDate != "" { startDateVal = *cond.StartDate } err := tx.QueryRow(` INSERT INTO score_conditions (project_id, required_points, start_date) VALUES ($1, $2, $3) ON CONFLICT (project_id, required_points, start_date) DO UPDATE SET required_points = EXCLUDED.required_points RETURNING id `, *cond.ProjectID, *cond.RequiredPoints, startDateVal).Scan(&scoreConditionID) if err != nil { log.Printf("saveWishlistConditionsWithUserID: error creating score condition: %v", err) return fmt.Errorf("error creating score condition: %w", err) } // Связываем с wishlist_item _, err = tx.Exec(` INSERT INTO wishlist_conditions (wishlist_item_id, user_id, score_condition_id, display_order) VALUES ($1, $2, $3, $4) `, wishlistItemID, userID, scoreConditionID, displayOrder) if err != nil { log.Printf("saveWishlistConditionsWithUserID: error linking score condition: %v", err) return fmt.Errorf("error linking score condition: %w", err) } } } return nil } // LinkMetadataResponse структура ответа с метаданными ссылки type LinkMetadataResponse struct { Title string `json:"title,omitempty"` Image string `json:"image,omitempty"` Price *float64 `json:"price,omitempty"` Description string `json:"description,omitempty"` } // extractMetadataViaHTTP извлекает метаданные через HTTP-запрос и парсинг HTML // Это стандартный метод, используемый Telegram, Facebook и другими сервисами func extractMetadataViaHTTP(targetURL string) (*LinkMetadataResponse, error) { // Валидация URL parsedURL, err := url.Parse(targetURL) if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { return nil, fmt.Errorf("invalid URL format: %s", targetURL) } // HTTP клиент с увеличенным таймаутом и поддержкой редиректов transport := &http.Transport{ DisableKeepAlives: false, MaxIdleConns: 10, IdleConnTimeout: 90 * time.Second, } client := &http.Client{ Timeout: 30 * time.Second, Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return fmt.Errorf("stopped after 10 redirects") } return nil }, } httpReq, err := http.NewRequest("GET", targetURL, nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } // Устанавливаем заголовки, максимально имитирующие реальный браузер Chrome httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") httpReq.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7") httpReq.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") httpReq.Header.Set("Connection", "keep-alive") httpReq.Header.Set("Upgrade-Insecure-Requests", "1") httpReq.Header.Set("Sec-Fetch-Dest", "document") httpReq.Header.Set("Sec-Fetch-Mode", "navigate") httpReq.Header.Set("Sec-Fetch-Site", "none") httpReq.Header.Set("Sec-Fetch-User", "?1") httpReq.Header.Set("Sec-Ch-Ua", `"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"`) httpReq.Header.Set("Sec-Ch-Ua-Mobile", "?0") httpReq.Header.Set("Sec-Ch-Ua-Platform", `"macOS"`) httpReq.Header.Set("Cache-Control", "max-age=0") httpReq.Header.Set("DNT", "1") if parsedURL.Host != "" { referer := fmt.Sprintf("%s://%s/", parsedURL.Scheme, parsedURL.Host) httpReq.Header.Set("Referer", referer) } time.Sleep(100 * time.Millisecond) resp, err := client.Do(httpReq) if err != nil { return nil, fmt.Errorf("error fetching URL: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) } limitedReader := io.LimitReader(resp.Body, 512*1024) bodyBytes, err := io.ReadAll(limitedReader) if err != nil { return nil, fmt.Errorf("error reading response: %w", err) } if len(bodyBytes) >= 2 && bodyBytes[0] == 0x1f && bodyBytes[1] == 0x8b { gzipReader, err := gzip.NewReader(bytes.NewReader(bodyBytes)) if err == nil { defer gzipReader.Close() decompressed, err := io.ReadAll(gzipReader) if err == nil { bodyBytes = decompressed } } } body := string(bodyBytes) metadata := &LinkMetadataResponse{} // Извлекаем Open Graph теги ogTitleRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["']`) ogTitleRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["']`) ogImageRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']+)["']`) ogImageRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:image["']`) ogDescRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["']`) ogDescRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:description["']`) if matches := ogTitleRe.FindStringSubmatch(body); len(matches) > 1 { metadata.Title = strings.TrimSpace(matches[1]) } else if matches := ogTitleRe2.FindStringSubmatch(body); len(matches) > 1 { metadata.Title = strings.TrimSpace(matches[1]) } if matches := ogImageRe.FindStringSubmatch(body); len(matches) > 1 { metadata.Image = strings.TrimSpace(matches[1]) } else if matches := ogImageRe2.FindStringSubmatch(body); len(matches) > 1 { metadata.Image = strings.TrimSpace(matches[1]) } if matches := ogDescRe.FindStringSubmatch(body); len(matches) > 1 { metadata.Description = strings.TrimSpace(matches[1]) } else if matches := ogDescRe2.FindStringSubmatch(body); len(matches) > 1 { metadata.Description = strings.TrimSpace(matches[1]) } if metadata.Title == "" { titleRe := regexp.MustCompile(`(?i)]*>([^<]+)`) if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 { metadata.Title = strings.TrimSpace(matches[1]) if strings.Contains(strings.ToLower(metadata.Title), "робот") || strings.Contains(strings.ToLower(metadata.Title), "captcha") || strings.Contains(strings.ToLower(metadata.Title), "вы не робот") { metadata.Title = "" metadata.Image = "" metadata.Description = "" } } } if metadata.Image == "" { twitterImageRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["']twitter:image["'][^>]*content\s*=\s*["']([^"']+)["']`) if matches := twitterImageRe.FindStringSubmatch(body); len(matches) > 1 { metadata.Image = strings.TrimSpace(matches[1]) } else { twitterImageRe2 := regexp.MustCompile(`(?i)]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']twitter:image["']`) if matches := twitterImageRe2.FindStringSubmatch(body); len(matches) > 1 { metadata.Image = strings.TrimSpace(matches[1]) } } } // Поиск цены jsonLdRe := regexp.MustCompile(`(?i)]*type\s*=\s*["']application/ld\+json["'][^>]*>([^<]+)`) jsonLdMatches := jsonLdRe.FindAllStringSubmatch(body, -1) for _, match := range jsonLdMatches { if len(match) > 1 { jsonStr := match[1] priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`) if priceMatches := priceRe.FindStringSubmatch(jsonStr); len(priceMatches) > 1 { priceStr := strings.ReplaceAll(priceMatches[1], ",", ".") if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 { metadata.Price = &price break } } } } if metadata.Price == nil { priceRe := regexp.MustCompile(`(?i)"price"\s*:\s*"?(\d+(?:[.,]\d+)?)"?`) if matches := priceRe.FindStringSubmatch(body); len(matches) > 1 { priceStr := strings.ReplaceAll(matches[1], ",", ".") if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 { metadata.Price = &price } } } if metadata.Price == nil { metaPriceRe := regexp.MustCompile(`(?i)]*(?:property|name)\s*=\s*["'](?:price|product:price)["'][^>]*content\s*=\s*["']([^"']+)["']`) if matches := metaPriceRe.FindStringSubmatch(body); len(matches) > 1 { priceStr := strings.ReplaceAll(strings.TrimSpace(matches[1]), ",", ".") priceStr = regexp.MustCompile(`[^\d.]`).ReplaceAllString(priceStr, "") if price, err := strconv.ParseFloat(priceStr, 64); err == nil && price > 0 && price < 100000000 { metadata.Price = &price } } } // Нормализуем URL изображения if metadata.Image != "" && !strings.HasPrefix(metadata.Image, "http") { baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) if strings.HasPrefix(metadata.Image, "//") { metadata.Image = parsedURL.Scheme + ":" + metadata.Image } else if strings.HasPrefix(metadata.Image, "/") { metadata.Image = baseURL + metadata.Image } else { metadata.Image = baseURL + "/" + metadata.Image } } metadata.Title = html.UnescapeString(metadata.Title) metadata.Description = html.UnescapeString(metadata.Description) return metadata, nil } // extractMetadataViaChrome извлекает метаданные через headless Chrome // Используется как fallback для JavaScript-рендеринга страниц func extractMetadataViaChrome(targetURL string) (*LinkMetadataResponse, error) { opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), chromedp.Flag("no-sandbox", true), chromedp.Flag("disable-dev-shm-usage", true), ) allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) defer cancel() ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) defer cancel() ctx, cancel = context.WithTimeout(ctx, 30*time.Second) defer cancel() metadata := &LinkMetadataResponse{} // Используем map для получения данных из JavaScript var result map[string]interface{} err := chromedp.Run(ctx, chromedp.Navigate(targetURL), chromedp.WaitVisible("body", chromedp.ByQuery), chromedp.Sleep(2*time.Second), // Даем время на выполнение JavaScript chromedp.Evaluate(` (function() { const result = { title: '', image: '', description: '', price: null }; // Извлекаем og:title const ogTitle = document.querySelector('meta[property="og:title"]'); if (ogTitle) { result.title = ogTitle.getAttribute('content') || ''; } else { // Fallback на обычный title const titleEl = document.querySelector('title'); if (titleEl) { result.title = titleEl.textContent || ''; } } // Извлекаем og:image const ogImage = document.querySelector('meta[property="og:image"]'); if (ogImage) { result.image = ogImage.getAttribute('content') || ''; } else { // Fallback на twitter:image const twitterImage = document.querySelector('meta[name="twitter:image"]'); if (twitterImage) { result.image = twitterImage.getAttribute('content') || ''; } } // Извлекаем og:description const ogDesc = document.querySelector('meta[property="og:description"]'); if (ogDesc) { result.description = ogDesc.getAttribute('content') || ''; } // Извлекаем цену из JSON-LD const jsonLdScripts = document.querySelectorAll('script[type="application/ld+json"]'); for (const script of jsonLdScripts) { try { const data = JSON.parse(script.textContent); if (data.offers && data.offers.price) { result.price = parseFloat(data.offers.price); break; } if (data.price) { result.price = parseFloat(data.price); break; } } catch (e) {} } // Если не нашли в JSON-LD, ищем в meta тегах if (!result.price) { const priceMeta = document.querySelector('meta[property="product:price:amount"]'); if (priceMeta) { result.price = parseFloat(priceMeta.getAttribute('content')); } } // Нормализуем URL изображения if (result.image && !result.image.startsWith('http')) { const baseURL = window.location.origin; if (result.image.startsWith('//')) { result.image = window.location.protocol + result.image; } else if (result.image.startsWith('/')) { result.image = baseURL + result.image; } else { result.image = baseURL + '/' + result.image; } } return result; })(); `, &result), ) if err != nil { return nil, fmt.Errorf("error extracting metadata via Chrome: %w", err) } // Преобразуем map в структуру if title, ok := result["title"].(string); ok { metadata.Title = strings.TrimSpace(title) } if image, ok := result["image"].(string); ok { metadata.Image = strings.TrimSpace(image) } if desc, ok := result["description"].(string); ok { metadata.Description = strings.TrimSpace(desc) } if priceVal := result["price"]; priceVal != nil { if priceFloat, ok := priceVal.(float64); ok { if priceFloat > 0 && priceFloat < 100000000 { metadata.Price = &priceFloat } } } // Валидация и очистка данных if metadata.Title != "" { metadata.Title = strings.TrimSpace(metadata.Title) if strings.Contains(strings.ToLower(metadata.Title), "робот") || strings.Contains(strings.ToLower(metadata.Title), "captcha") || strings.Contains(strings.ToLower(metadata.Title), "вы не робот") { metadata.Title = "" metadata.Image = "" metadata.Description = "" } } if metadata.Price != nil && (*metadata.Price <= 0 || *metadata.Price >= 100000000) { metadata.Price = nil } return metadata, nil } // extractLinkMetadataHandler извлекает метаданные (Open Graph, Title, Image) из URL // Использует HTTP-метод как основной (стандартный подход), chromedp как fallback func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) var req struct { URL string `json:"url"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { log.Printf("Error decoding metadata request body: %v", err) sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if req.URL == "" { log.Printf("Empty URL in metadata request") sendErrorWithCORS(w, "URL is required", http.StatusBadRequest) return } // Валидация URL _, err := url.Parse(req.URL) if err != nil { log.Printf("Invalid URL format: %s, error: %v", req.URL, err) sendErrorWithCORS(w, "Invalid URL", http.StatusBadRequest) return } log.Printf("Extracting metadata for URL: %s", req.URL) // Шаг 1: Пытаемся получить метаданные через HTTP-метод (основной, быстрый метод) metadata, err := extractMetadataViaHTTP(req.URL) if err != nil { log.Printf("HTTP method failed for URL %s: %v", req.URL, err) metadata = &LinkMetadataResponse{} // Инициализируем пустую структуру для fallback } // Шаг 2: Проверяем, достаточно ли данных из HTTP-метода // Если нет title и image, используем chromedp fallback needsFallback := (metadata.Title == "" && metadata.Image == "") if needsFallback { log.Printf("HTTP method didn't return enough data for URL %s, trying Chrome fallback", req.URL) chromeMetadata, chromeErr := extractMetadataViaChrome(req.URL) if chromeErr != nil { log.Printf("Chrome fallback failed for URL %s: %v", req.URL, chromeErr) // Возвращаем результаты HTTP-метода, даже если они пустые } else { // Объединяем результаты: приоритет у HTTP, дополняем из Chrome if metadata.Title == "" && chromeMetadata.Title != "" { metadata.Title = chromeMetadata.Title log.Printf("Chrome fallback provided title: %s", chromeMetadata.Title) } if metadata.Image == "" && chromeMetadata.Image != "" { metadata.Image = chromeMetadata.Image log.Printf("Chrome fallback provided image: %s", chromeMetadata.Image) } if metadata.Description == "" && chromeMetadata.Description != "" { metadata.Description = chromeMetadata.Description } if metadata.Price == nil && chromeMetadata.Price != nil { metadata.Price = chromeMetadata.Price } } } else { log.Printf("HTTP method successfully extracted metadata for URL %s", req.URL) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(metadata) } // proxyImageHandler проксирует изображение через бэкенд для обхода CORS func (a *App) proxyImageHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } imageURL := r.URL.Query().Get("url") if imageURL == "" { sendErrorWithCORS(w, "URL parameter is required", http.StatusBadRequest) return } // Валидация URL parsedURL, err := url.Parse(imageURL) if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { log.Printf("Invalid image URL: %s", imageURL) sendErrorWithCORS(w, "Invalid URL", http.StatusBadRequest) return } log.Printf("Proxying image for user %d: %s", userID, imageURL) // Создаем HTTP запрос к изображению client := &http.Client{ Timeout: 30 * time.Second, } req, err := http.NewRequest("GET", imageURL, nil) if err != nil { log.Printf("Error creating image request: %v", err) sendErrorWithCORS(w, "Error creating request", http.StatusInternalServerError) return } // Устанавливаем User-Agent для имитации браузера req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") req.Header.Set("Referer", parsedURL.Scheme+"://"+parsedURL.Host+"/") resp, err := client.Do(req) if err != nil { log.Printf("Error fetching image: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error fetching image: %v", err), http.StatusBadGateway) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { log.Printf("Image fetch returned status %d", resp.StatusCode) sendErrorWithCORS(w, fmt.Sprintf("HTTP %d", resp.StatusCode), http.StatusBadGateway) return } // Ограничиваем размер (максимум 5MB) limitedReader := io.LimitReader(resp.Body, 5*1024*1024) bodyBytes, err := io.ReadAll(limitedReader) if err != nil { log.Printf("Error reading image: %v", err) sendErrorWithCORS(w, "Error reading image", http.StatusInternalServerError) return } // Определяем Content-Type contentType := resp.Header.Get("Content-Type") if contentType == "" { // Пытаемся определить по содержимому if len(bodyBytes) >= 2 { if bodyBytes[0] == 0xFF && bodyBytes[1] == 0xD8 { contentType = "image/jpeg" } else if len(bodyBytes) >= 8 && string(bodyBytes[0:8]) == "\x89PNG\r\n\x1a\n" { contentType = "image/png" } else if len(bodyBytes) >= 4 && string(bodyBytes[0:4]) == "RIFF" { contentType = "image/webp" } else { contentType = "application/octet-stream" } } } // Проверяем, что это изображение if !strings.HasPrefix(contentType, "image/") { log.Printf("Invalid content type: %s", contentType) sendErrorWithCORS(w, "Not an image", http.StatusBadRequest) return } // Отправляем изображение w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Length", strconv.Itoa(len(bodyBytes))) w.Header().Set("Cache-Control", "public, max-age=3600") w.WriteHeader(http.StatusOK) w.Write(bodyBytes) } // ============================================ // Tracking handlers // ============================================ // getWeeklyStatsDataForUserAndWeek получает данные о проектах для конкретного пользователя и недели func (a *App) getWeeklyStatsDataForUserAndWeek(userID int, year int, week int) (*WeeklyStatsResponse, error) { // Определяем, является ли это текущей неделей (в настроенном часовом поясе) timezoneStr := getEnv("TIMEZONE", "UTC") loc, locErr := time.LoadLocation(timezoneStr) if locErr != nil { loc = time.UTC } now := time.Now().In(loc) currentYear, currentWeek := now.ISOWeek() isCurrentWeek := year == currentYear && week == currentWeek var currentWeekScores map[int]float64 var err error if isCurrentWeek { // Для текущей недели используем realtime данные currentWeekScores, err = a.getCurrentWeekScores(userID) if err != nil { log.Printf("Error getting current week scores: %v", err) return nil, fmt.Errorf("error getting current week scores: %w", err) } } else { // Для исторических недель используем пустой map (данные из MV) currentWeekScores = make(map[int]float64) } 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, wg.max_goal_score, wg.priority AS priority, p.color FROM projects p INNER JOIN weekly_goals wg ON wg.project_id = p.id AND wg.goal_year = $2 AND wg.goal_week = $3 LEFT JOIN weekly_report_mv wr ON p.id = wr.project_id AND $2 = wr.report_year AND $3 = wr.report_week WHERE p.deleted = FALSE AND p.user_id = $1 AND wg.min_goal_score IS NOT NULL ORDER BY total_score DESC ` rows, err := a.DB.Query(query, userID, year, week) if err != nil { return nil, fmt.Errorf("error querying weekly stats: %w", err) } defer rows.Close() projects := make([]WeeklyProjectStats, 0) groups := make(map[int][]float64) for rows.Next() { var project WeeklyProjectStats var projectID int var minGoalScore sql.NullFloat64 var maxGoalScore sql.NullFloat64 var priority sql.NullInt64 err := rows.Scan( &projectID, &project.ProjectName, &project.TotalScore, &minGoalScore, &maxGoalScore, &priority, &project.Color, ) if err != nil { return nil, fmt.Errorf("error scanning weekly stats row: %w", err) } project.ProjectID = projectID // Объединяем данные: если это текущая неделя и есть данные, используем их вместо MV if isCurrentWeek { if currentWeekScore, exists := currentWeekScores[projectID]; exists { project.TotalScore = currentWeekScore } } if minGoalScore.Valid { project.MinGoalScore = minGoalScore.Float64 } else { project.MinGoalScore = 0 } if maxGoalScore.Valid { maxGoalVal := maxGoalScore.Float64 project.MaxGoalScore = &maxGoalVal } var priorityVal int if priority.Valid { priorityVal = int(priority.Int64) project.Priority = &priorityVal } // Расчет calculated_score totalScore := project.TotalScore minGoalScoreVal := project.MinGoalScore var maxGoalScoreVal float64 if project.MaxGoalScore != nil { maxGoalScoreVal = *project.MaxGoalScore } // Параметры бонуса в зависимости от priority var extraBonusLimit float64 = 20 if priorityVal == 1 { extraBonusLimit = 50 } else if priorityVal == 2 { extraBonusLimit = 35 } // Расчет calculated_score по логике фронтенда // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета var resultScore float64 if minGoalScoreVal <= 0 { // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) resultScore = 0 } else if totalScore < minGoalScoreVal { // До достижения minGoal растем линейно от 0 до 100% resultScore = (totalScore / minGoalScoreVal) * 100.0 } else { // Достигнут minGoal - базовый прогресс = 100% baseProgress := 100.0 // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс if maxGoalScoreVal > minGoalScoreVal { extraRange := maxGoalScoreVal - minGoalScoreVal excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal extraRatio := min(1.0, max(0.0, excess/extraRange)) extraProgress := extraRatio * extraBonusLimit resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) } else { // Если maxGoal не задан или некорректен, просто 100% resultScore = baseProgress } } project.CalculatedScore = roundToTwoDecimals(resultScore) projects = append(projects, project) // Группировка для итогового расчета if minGoalScoreVal > 0 { if _, exists := groups[priorityVal]; !exists { groups[priorityVal] = make([]float64, 0) } groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) } } // Вычисляем проценты для каждой группы groupsProgress := calculateGroupsProgress(groups) // Вычисляем общий процент выполнения total := calculateOverallProgress(groupsProgress, groups) response := WeeklyStatsResponse{ Total: total, GroupProgress1: groupsProgress.Group1, GroupProgress2: groupsProgress.Group2, GroupProgress0: groupsProgress.Group0, Projects: projects, } return &response, nil } // getISOWeek вычисляет номер недели ISO для даты func getISOWeek(t time.Time) int { _, week := t.ISOWeek() return week } // getTrackingStatsHandler возвращает статистику недели для текущего пользователя и отслеживаемых func (a *App) getTrackingStatsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Получаем year и week из query params yearStr := r.URL.Query().Get("year") weekStr := r.URL.Query().Get("week") var year, week int timezoneStr := getEnv("TIMEZONE", "UTC") loc, locErr := time.LoadLocation(timezoneStr) if locErr != nil { loc = time.UTC } now := time.Now().In(loc) if yearStr == "" || weekStr == "" { // Если не указаны - текущая неделя year, week = now.ISOWeek() } else { var err error year, err = strconv.Atoi(yearStr) if err != nil { sendErrorWithCORS(w, "Invalid year parameter", http.StatusBadRequest) return } week, err = strconv.Atoi(weekStr) if err != nil { sendErrorWithCORS(w, "Invalid week parameter", http.StatusBadRequest) return } } // Получаем список отслеживаемых пользователей rows, err := a.DB.Query(` SELECT tracked_id FROM user_tracking WHERE tracker_id = $1 `, userID) if err != nil { log.Printf("Error getting tracked users: %v", err) sendErrorWithCORS(w, "Error getting tracked users", http.StatusInternalServerError) return } defer rows.Close() trackedUserIDs := []int{userID} // Начинаем с текущего пользователя for rows.Next() { var trackedID int if err := rows.Scan(&trackedID); err != nil { log.Printf("Error scanning tracked user: %v", err) continue } trackedUserIDs = append(trackedUserIDs, trackedID) } // Получаем данные для каждого пользователя users := make([]TrackingUserStats, 0) for _, uid := range trackedUserIDs { stats, err := a.getWeeklyStatsDataForUserAndWeek(uid, year, week) if err != nil { log.Printf("Error getting stats for user %d: %v", uid, err) continue } // Получаем имя пользователя var userName string err = a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, uid).Scan(&userName) if err != nil { log.Printf("Error getting user name: %v", err) userName = "Unknown" } // Преобразуем проекты в TrackingProjectStats projects := make([]TrackingProjectStats, 0) for _, p := range stats.Projects { projects = append(projects, TrackingProjectStats{ ProjectName: p.ProjectName, CalculatedScore: p.CalculatedScore, Priority: p.Priority, }) } // Сортируем проекты по priority (1, 2, остальные) sort.Slice(projects, func(i, j int) bool { pi, pj := 99, 99 if projects[i].Priority != nil { pi = *projects[i].Priority } if projects[j].Priority != nil { pj = *projects[j].Priority } return pi < pj }) var confirmedYear, confirmedWeek int err = a.DB.QueryRow( `SELECT priorities_confirmed_year, priorities_confirmed_week FROM users WHERE id = $1`, uid, ).Scan(&confirmedYear, &confirmedWeek) if err != nil { log.Printf("Error getting priorities confirmed for user %d: %v", uid, err) } users = append(users, TrackingUserStats{ UserID: uid, UserName: userName, IsCurrentUser: uid == userID, Total: stats.Total, Projects: projects, PrioritiesConfirmedYear: confirmedYear, PrioritiesConfirmedWeek: confirmedWeek, }) } // Сортируем: текущий пользователь всегда первый sortedUsers := make([]TrackingUserStats, 0) for _, u := range users { if u.IsCurrentUser { sortedUsers = append([]TrackingUserStats{u}, sortedUsers...) } else { sortedUsers = append(sortedUsers, u) } } response := TrackingStatsResponse{ WeekNumber: week, Year: year, Users: sortedUsers, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // createTrackingInviteHandler создает токен приглашения func (a *App) createTrackingInviteHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Генерируем токен token := generateInviteToken() // Сохраняем в базу с истечением через 1 час _, err := a.DB.Exec(` INSERT INTO tracking_invite_tokens (user_id, token, expires_at) VALUES ($1, $2, NOW() + INTERVAL '1 hour') `, userID, token) if err != nil { log.Printf("Error creating tracking invite token: %v", err) sendErrorWithCORS(w, "Error creating invite token", http.StatusInternalServerError) return } // Формируем URL baseURL := getEnv("WEBHOOK_BASE_URL", "") inviteURL := baseURL + "/tracking/invite/" + token response := TrackingInviteResponse{ InviteURL: inviteURL, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // getTrackingInviteInfoHandler возвращает информацию о приглашении func (a *App) getTrackingInviteInfoHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) vars := mux.Vars(r) token := vars["token"] var userID int var expiresAt time.Time err := a.DB.QueryRow(` SELECT user_id, expires_at FROM tracking_invite_tokens WHERE token = $1 `, token).Scan(&userID, &expiresAt) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound) return } if err != nil { log.Printf("Error getting invite token: %v", err) sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) return } // Проверяем срок действия if time.Now().After(expiresAt) { sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound) return } // Получаем имя пользователя var userName string err = a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, userID).Scan(&userName) if err != nil { log.Printf("Error getting user name: %v", err) sendErrorWithCORS(w, "Error getting user info", http.StatusInternalServerError) return } response := TrackingInviteInfo{ UserID: userID, UserName: userName, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // acceptTrackingInviteHandler принимает приглашение на отслеживание func (a *App) acceptTrackingInviteHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) currentUserID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) token := vars["token"] // Получаем информацию о токене var trackedUserID int var expiresAt time.Time err := a.DB.QueryRow(` SELECT user_id, expires_at FROM tracking_invite_tokens WHERE token = $1 AND expires_at > NOW() `, token).Scan(&trackedUserID, &expiresAt) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound) return } if err != nil { log.Printf("Error getting invite token: %v", err) sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) return } // Проверяем, что пользователь не пытается отслеживать себя if trackedUserID == currentUserID { sendErrorWithCORS(w, "Нельзя отслеживать себя", http.StatusBadRequest) return } // Создаем запись отслеживания _, err = a.DB.Exec(` INSERT INTO user_tracking (tracker_id, tracked_id) VALUES ($1, $2) ON CONFLICT (tracker_id, tracked_id) DO NOTHING `, currentUserID, trackedUserID) if err != nil { log.Printf("Error creating tracking relation: %v", err) sendErrorWithCORS(w, "Error accepting invite", http.StatusInternalServerError) return } // Удаляем использованный токен (одноразовый) _, err = a.DB.Exec(`DELETE FROM tracking_invite_tokens WHERE token = $1`, token) if err != nil { log.Printf("Error deleting used token: %v", err) // Не критично, продолжаем } // Получаем имя пользователя для ответа var userName string a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, trackedUserID).Scan(&userName) response := map[string]interface{}{ "success": true, "tracked_user_name": userName, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // getTrackingAccessHandler возвращает списки трекеров и отслеживаемых func (a *App) getTrackingAccessHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Trackers (кто меня отслеживает) trackers := make([]TrackingUser, 0) rows, err := a.DB.Query(` SELECT ut.id as relation_id, u.id, COALESCE(u.name, u.email) as name, u.email, ut.created_at FROM user_tracking ut JOIN users u ON ut.tracker_id = u.id WHERE ut.tracked_id = $1 ORDER BY ut.created_at DESC `, userID) if err != nil { log.Printf("Error getting trackers: %v", err) sendErrorWithCORS(w, "Error getting trackers", http.StatusInternalServerError) return } defer rows.Close() for rows.Next() { var t TrackingUser if err := rows.Scan(&t.RelationID, &t.ID, &t.Name, &t.Email, &t.CreatedAt); err != nil { log.Printf("Error scanning tracker: %v", err) continue } trackers = append(trackers, t) } // Tracked (кого я отслеживаю) tracked := make([]TrackingUser, 0) rows, err = a.DB.Query(` SELECT ut.id as relation_id, u.id, COALESCE(u.name, u.email) as name, u.email, ut.created_at FROM user_tracking ut JOIN users u ON ut.tracked_id = u.id WHERE ut.tracker_id = $1 ORDER BY ut.created_at DESC `, userID) if err != nil { log.Printf("Error getting tracked: %v", err) sendErrorWithCORS(w, "Error getting tracked", http.StatusInternalServerError) return } defer rows.Close() for rows.Next() { var t TrackingUser if err := rows.Scan(&t.RelationID, &t.ID, &t.Name, &t.Email, &t.CreatedAt); err != nil { log.Printf("Error scanning tracked: %v", err) continue } tracked = append(tracked, t) } response := TrackingAccessResponse{ Trackers: trackers, Tracked: tracked, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // deleteTrackingTrackerHandler удаляет того, кто меня отслеживает func (a *App) deleteTrackingTrackerHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) relationIDStr := vars["id"] relationID, err := strconv.Atoi(relationIDStr) if err != nil { sendErrorWithCORS(w, "Invalid relation ID", http.StatusBadRequest) return } // Удаляем только если это действительно тот, кто отслеживает меня result, err := a.DB.Exec(` DELETE FROM user_tracking WHERE id = $1 AND tracked_id = $2 `, relationID, userID) if err != nil { log.Printf("Error deleting tracker relation: %v", err) sendErrorWithCORS(w, "Error deleting relation", http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { sendErrorWithCORS(w, "Relation not found or access denied", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) } // deleteTrackingTrackedHandler прекращает отслеживать пользователя func (a *App) deleteTrackingTrackedHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) relationIDStr := vars["id"] relationID, err := strconv.Atoi(relationIDStr) if err != nil { sendErrorWithCORS(w, "Invalid relation ID", http.StatusBadRequest) return } // Удаляем только если это действительно тот, кого я отслеживаю result, err := a.DB.Exec(` DELETE FROM user_tracking WHERE id = $1 AND tracker_id = $2 `, relationID, userID) if err != nil { log.Printf("Error deleting tracked relation: %v", err) sendErrorWithCORS(w, "Error deleting relation", http.StatusInternalServerError) return } rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { sendErrorWithCORS(w, "Relation not found or access denied", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) } // decodeHTMLEntities декодирует базовые HTML entities func decodeHTMLEntities(s string) string { replacements := map[string]string{ "&": "&", "<": "<", ">": ">", """: "\"", "'": "'", "'": "'", " ": " ", "—": "—", "–": "–", "«": "«", "»": "»", } for entity, char := range replacements { s = strings.ReplaceAll(s, entity, char) } return s } // refreshGroupSuggestionsMV обновляет materialized view для групповых саджестов func (a *App) refreshGroupSuggestionsMV() error { _, err := a.DB.Exec("REFRESH MATERIALIZED VIEW CONCURRENTLY user_group_suggestions_mv") if err != nil { log.Printf("Error refreshing user_group_suggestions_mv: %v", err) return err } return nil } // getGroupSuggestionsHandler возвращает список уникальных имён групп для текущего пользователя func (a *App) getGroupSuggestionsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } query := ` SELECT DISTINCT group_name FROM user_group_suggestions_mv WHERE user_id = $1 ORDER BY group_name ` rows, err := a.DB.Query(query, userID) if err != nil { log.Printf("Error querying group suggestions: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error querying group suggestions: %v", err), http.StatusInternalServerError) return } defer rows.Close() groups := make([]string, 0) for rows.Next() { var groupName string if err := rows.Scan(&groupName); err != nil { log.Printf("Error scanning group name: %v", err) continue } groups = append(groups, groupName) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(groups) } // ============================================ // Shopping (Товары) - Board & Item handlers // ============================================ type ShoppingBoard struct { ID int `json:"id"` OwnerID int `json:"owner_id"` OwnerName string `json:"owner_name,omitempty"` Name string `json:"name"` InviteEnabled bool `json:"invite_enabled"` InviteToken *string `json:"invite_token,omitempty"` InviteURL *string `json:"invite_url,omitempty"` MemberCount int `json:"member_count"` IsOwner bool `json:"is_owner"` CreatedAt time.Time `json:"created_at"` } type ShoppingItem struct { ID int `json:"id"` UserID int `json:"user_id"` BoardID int `json:"board_id"` AuthorID int `json:"author_id"` Name string `json:"name"` Description *string `json:"description,omitempty"` GroupName *string `json:"group_name,omitempty"` VolumeBase float64 `json:"volume_base"` RepetitionPeriod *string `json:"repetition_period,omitempty"` NextShowAt *string `json:"next_show_at,omitempty"` Completed int `json:"completed"` LastCompletedAt *string `json:"last_completed_at,omitempty"` CreatedAt string `json:"created_at"` LastVolume *float64 `json:"last_volume,omitempty"` } type ShoppingItemRequest struct { Name string `json:"name"` Description *string `json:"description,omitempty"` GroupName *string `json:"group_name,omitempty"` VolumeBase *float64 `json:"volume_base,omitempty"` RepetitionPeriod *string `json:"repetition_period,omitempty"` } type CompleteShoppingItemRequest struct { Volume *float64 `json:"volume,omitempty"` } type ShoppingJoinBoardResponse struct { Board ShoppingBoard `json:"board"` Message string `json:"message"` } // getShoppingBoardsHandler возвращает список досок покупок пользователя func (a *App) getShoppingBoardsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } boards := []ShoppingBoard{} rows, err := a.DB.Query(` SELECT DISTINCT sb.id, sb.owner_id, COALESCE(u.name, u.email) as owner_name, sb.name, sb.invite_enabled, sb.invite_token, sb.created_at, (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count, (sb.owner_id = $1) as is_owner FROM shopping_boards sb JOIN users u ON sb.owner_id = u.id LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_id WHERE sb.deleted = FALSE AND (sb.owner_id = $1 OR sbm.user_id = $1) ORDER BY is_owner DESC, sb.created_at DESC `, userID) if err != nil { log.Printf("Error getting shopping boards: %v", err) sendErrorWithCORS(w, "Error getting boards", http.StatusInternalServerError) return } defer rows.Close() baseURL := getEnv("WEBHOOK_BASE_URL", "") for rows.Next() { var board ShoppingBoard var inviteToken sql.NullString err := rows.Scan( &board.ID, &board.OwnerID, &board.OwnerName, &board.Name, &board.InviteEnabled, &inviteToken, &board.CreatedAt, &board.MemberCount, &board.IsOwner, ) if err != nil { log.Printf("Error scanning shopping board: %v", err) continue } if board.IsOwner && inviteToken.Valid { board.InviteToken = &inviteToken.String if baseURL != "" { url := baseURL + "/shopping-invite/" + inviteToken.String board.InviteURL = &url } } boards = append(boards, board) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(boards) } // createShoppingBoardHandler создаёт новую доску покупок func (a *App) createShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } var req BoardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if strings.TrimSpace(req.Name) == "" { sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) return } var boardID int err := a.DB.QueryRow(` INSERT INTO shopping_boards (owner_id, name) VALUES ($1, $2) RETURNING id `, userID, strings.TrimSpace(req.Name)).Scan(&boardID) if err != nil { log.Printf("Error creating shopping board: %v", err) sendErrorWithCORS(w, "Error creating board", http.StatusInternalServerError) return } board := ShoppingBoard{ ID: boardID, OwnerID: userID, Name: strings.TrimSpace(req.Name), InviteEnabled: false, MemberCount: 0, IsOwner: true, CreatedAt: time.Now(), } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(board) } // getShoppingBoardHandler возвращает детали доски покупок func (a *App) getShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } var board ShoppingBoard var inviteToken sql.NullString err = a.DB.QueryRow(` SELECT sb.id, sb.owner_id, COALESCE(u.name, u.email) as owner_name, sb.name, sb.invite_enabled, sb.invite_token, sb.created_at, (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count FROM shopping_boards sb JOIN users u ON sb.owner_id = u.id WHERE sb.id = $1 AND sb.deleted = FALSE `, boardID).Scan( &board.ID, &board.OwnerID, &board.OwnerName, &board.Name, &board.InviteEnabled, &inviteToken, &board.CreatedAt, &board.MemberCount, ) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if err != nil { log.Printf("Error getting shopping board: %v", err) sendErrorWithCORS(w, "Error getting board", http.StatusInternalServerError) return } board.IsOwner = board.OwnerID == userID if !board.IsOwner { var isMember bool a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2) `, boardID, userID).Scan(&isMember) if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } } if board.IsOwner && inviteToken.Valid { board.InviteToken = &inviteToken.String baseURL := getEnv("WEBHOOK_BASE_URL", "") if baseURL != "" { url := baseURL + "/shopping-invite/" + inviteToken.String board.InviteURL = &url } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(board) } // updateShoppingBoardHandler обновляет доску покупок (только владелец) func (a *App) updateShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { sendErrorWithCORS(w, "Only owner can update board", http.StatusForbidden) return } var req BoardRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if strings.TrimSpace(req.Name) != "" { _, err = a.DB.Exec(`UPDATE shopping_boards SET name = $1, updated_at = NOW() WHERE id = $2`, strings.TrimSpace(req.Name), boardID) if err != nil { log.Printf("Error updating shopping board name: %v", err) } } if req.InviteEnabled != nil { if *req.InviteEnabled { var currentToken sql.NullString a.DB.QueryRow(`SELECT invite_token FROM shopping_boards WHERE id = $1`, boardID).Scan(¤tToken) if !currentToken.Valid || currentToken.String == "" { token := generateInviteToken() _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_enabled = TRUE, invite_token = $1, updated_at = NOW() WHERE id = $2`, token, boardID) } else { _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_enabled = TRUE, updated_at = NOW() WHERE id = $1`, boardID) } } else { _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_enabled = FALSE, updated_at = NOW() WHERE id = $1`, boardID) } if err != nil { log.Printf("Error updating shopping board invite_enabled: %v", err) } } var board ShoppingBoard var inviteToken sql.NullString a.DB.QueryRow(` SELECT sb.id, sb.owner_id, COALESCE(u.name, u.email), sb.name, sb.invite_enabled, sb.invite_token, sb.created_at, (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) FROM shopping_boards sb JOIN users u ON sb.owner_id = u.id WHERE sb.id = $1 `, boardID).Scan(&board.ID, &board.OwnerID, &board.OwnerName, &board.Name, &board.InviteEnabled, &inviteToken, &board.CreatedAt, &board.MemberCount) board.IsOwner = true if inviteToken.Valid { board.InviteToken = &inviteToken.String baseURL := getEnv("WEBHOOK_BASE_URL", "") if baseURL != "" { url := baseURL + "/shopping-invite/" + inviteToken.String board.InviteURL = &url } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(board) } // deleteShoppingBoardHandler удаляет доску покупок (только владелец) func (a *App) deleteShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { sendErrorWithCORS(w, "Only owner can delete board", http.StatusForbidden) return } _, err = a.DB.Exec(`UPDATE shopping_boards SET deleted = TRUE, updated_at = NOW() WHERE id = $1`, boardID) if err != nil { log.Printf("Error deleting shopping board: %v", err) sendErrorWithCORS(w, "Error deleting board", http.StatusInternalServerError) return } _, err = a.DB.Exec(`UPDATE shopping_items SET deleted = TRUE, updated_at = NOW() WHERE board_id = $1`, boardID) if err != nil { log.Printf("Error deleting shopping board items: %v", err) } w.WriteHeader(http.StatusNoContent) } // regenerateShoppingBoardInviteHandler перегенерирует invite token для доски покупок func (a *App) regenerateShoppingBoardInviteHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { sendErrorWithCORS(w, "Only owner can regenerate invite", http.StatusForbidden) return } token := generateInviteToken() _, err = a.DB.Exec(`UPDATE shopping_boards SET invite_token = $1, invite_enabled = TRUE, updated_at = NOW() WHERE id = $2`, token, boardID) if err != nil { log.Printf("Error regenerating shopping invite token: %v", err) sendErrorWithCORS(w, "Error regenerating invite", http.StatusInternalServerError) return } baseURL := getEnv("WEBHOOK_BASE_URL", "") inviteURL := "" if baseURL != "" { inviteURL = baseURL + "/shopping-invite/" + token } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "invite_token": token, "invite_url": inviteURL, }) } // getShoppingBoardMembersHandler возвращает список участников доски покупок func (a *App) getShoppingBoardMembersHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { sendErrorWithCORS(w, "Only owner can view members", http.StatusForbidden) return } members := []BoardMember{} rows, err := a.DB.Query(` SELECT sbm.id, sbm.user_id, COALESCE(u.name, '') as name, u.email, sbm.joined_at FROM shopping_board_members sbm JOIN users u ON sbm.user_id = u.id WHERE sbm.board_id = $1 ORDER BY sbm.joined_at DESC `, boardID) if err != nil { log.Printf("Error getting shopping board members: %v", err) sendErrorWithCORS(w, "Error getting members", http.StatusInternalServerError) return } defer rows.Close() for rows.Next() { var member BoardMember err := rows.Scan(&member.ID, &member.UserID, &member.Name, &member.Email, &member.JoinedAt) if err != nil { log.Printf("Error scanning shopping board member: %v", err) continue } members = append(members, member) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(members) } // removeShoppingBoardMemberHandler удаляет участника из доски покупок func (a *App) removeShoppingBoardMemberHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } memberUserID, err := strconv.Atoi(vars["userId"]) if err != nil { sendErrorWithCORS(w, "Invalid user ID", http.StatusBadRequest) return } var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { sendErrorWithCORS(w, "Only owner can remove members", http.StatusForbidden) return } _, err = a.DB.Exec(`DELETE FROM shopping_board_members WHERE board_id = $1 AND user_id = $2`, boardID, memberUserID) if err != nil { log.Printf("Error removing shopping board member: %v", err) sendErrorWithCORS(w, "Error removing member", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // leaveShoppingBoardHandler позволяет участнику выйти из доски покупок func (a *App) leaveShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID == userID { sendErrorWithCORS(w, "Owner cannot leave board, delete it instead", http.StatusBadRequest) return } _, err = a.DB.Exec(`DELETE FROM shopping_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID) if err != nil { log.Printf("Error leaving shopping board: %v", err) sendErrorWithCORS(w, "Error leaving board", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // getShoppingBoardInviteInfoHandler возвращает информацию о доске покупок по invite token func (a *App) getShoppingBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) vars := mux.Vars(r) token := vars["token"] var info BoardInviteInfo var ownerName string err := a.DB.QueryRow(` SELECT sb.id, sb.name, COALESCE(u.name, u.email) as owner_name, (SELECT COUNT(*) FROM shopping_board_members sbm WHERE sbm.board_id = sb.id) as member_count FROM shopping_boards sb JOIN users u ON sb.owner_id = u.id WHERE sb.invite_token = $1 AND sb.invite_enabled = TRUE AND sb.deleted = FALSE `, token).Scan(&info.BoardID, &info.Name, &ownerName, &info.MemberCount) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound) return } if err != nil { log.Printf("Error getting shopping invite info: %v", err) sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) return } info.OwnerName = ownerName w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(info) } // joinShoppingBoardHandler присоединяет пользователя к доске покупок по invite token func (a *App) joinShoppingBoardHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) token := vars["token"] var boardID, ownerID int var boardName, ownerName string err := a.DB.QueryRow(` SELECT sb.id, sb.owner_id, sb.name, COALESCE(u.name, u.email) FROM shopping_boards sb JOIN users u ON sb.owner_id = u.id WHERE sb.invite_token = $1 AND sb.invite_enabled = TRUE AND sb.deleted = FALSE `, token).Scan(&boardID, &ownerID, &boardName, &ownerName) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound) return } if err != nil { log.Printf("Error getting shopping board by token: %v", err) sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError) return } if ownerID == userID { sendErrorWithCORS(w, "You are the owner of this board", http.StatusBadRequest) return } var exists bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_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) return } _, err = a.DB.Exec(`INSERT INTO shopping_board_members (board_id, user_id) VALUES ($1, $2)`, boardID, userID) if err != nil { log.Printf("Error joining shopping board: %v", err) sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError) return } var memberCount int a.DB.QueryRow(`SELECT COUNT(*) FROM shopping_board_members WHERE board_id = $1`, boardID).Scan(&memberCount) board := ShoppingBoard{ ID: boardID, OwnerID: ownerID, OwnerName: ownerName, Name: boardName, InviteEnabled: true, MemberCount: memberCount, IsOwner: false, } response := ShoppingJoinBoardResponse{ Board: board, Message: "Вы успешно присоединились к доске!", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(response) } // getShoppingItemsHandler возвращает все товары на доске func (a *App) getShoppingItemsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["boardId"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } // Проверяем доступ var ownerID int err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if ownerID != userID { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } } items := []ShoppingItem{} rows, err := a.DB.Query(` SELECT si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, si.group_name, si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, si.last_completed_at, si.created_at FROM shopping_items si WHERE si.board_id = $1 AND si.deleted = FALSE ORDER BY si.created_at ASC `, boardID) if err != nil { log.Printf("Error getting shopping items: %v", err) sendErrorWithCORS(w, "Error getting items", http.StatusInternalServerError) return } defer rows.Close() for rows.Next() { var item ShoppingItem var description sql.NullString var groupName sql.NullString var repetitionPeriod sql.NullString var nextShowAt sql.NullTime var lastCompletedAt sql.NullTime var createdAt time.Time err := rows.Scan( &item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &description, &groupName, &item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed, &lastCompletedAt, &createdAt, ) if err != nil { log.Printf("Error scanning shopping item: %v", err) continue } if description.Valid { item.Description = &description.String } if groupName.Valid { item.GroupName = &groupName.String } if repetitionPeriod.Valid { item.RepetitionPeriod = &repetitionPeriod.String } if nextShowAt.Valid { s := nextShowAt.Time.Format(time.RFC3339) item.NextShowAt = &s } if lastCompletedAt.Valid { s := lastCompletedAt.Time.Format(time.RFC3339) item.LastCompletedAt = &s } item.CreatedAt = createdAt.Format(time.RFC3339) items = append(items, item) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(items) } // createShoppingItemHandler создаёт новый товар на доске func (a *App) createShoppingItemHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) boardID, err := strconv.Atoi(vars["boardId"]) if err != nil { sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) return } // Проверяем доступ var boardOwnerID int err = a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&boardOwnerID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Board not found", http.StatusNotFound) return } if boardOwnerID != userID { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } } var req ShoppingItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if strings.TrimSpace(req.Name) == "" { sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) return } volumeBase := 1.0 if req.VolumeBase != nil && *req.VolumeBase > 0 { volumeBase = *req.VolumeBase } var itemID int err = a.DB.QueryRow(` INSERT INTO shopping_items (user_id, board_id, author_id, name, description, group_name, volume_base, repetition_period) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::interval) RETURNING id `, boardOwnerID, boardID, userID, strings.TrimSpace(req.Name), req.Description, req.GroupName, volumeBase, req.RepetitionPeriod).Scan(&itemID) if err != nil { log.Printf("Error creating shopping item: %v", err) sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError) return } item := ShoppingItem{ ID: itemID, UserID: boardOwnerID, BoardID: boardID, AuthorID: userID, Name: strings.TrimSpace(req.Name), Description: req.Description, GroupName: req.GroupName, VolumeBase: volumeBase, RepetitionPeriod: req.RepetitionPeriod, Completed: 0, CreatedAt: time.Now().Format(time.RFC3339), } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(item) } // getShoppingItemHandler возвращает детали товара func (a *App) getShoppingItemHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) return } var item ShoppingItem var description sql.NullString var groupName sql.NullString var repetitionPeriod sql.NullString var nextShowAt sql.NullTime var lastCompletedAt sql.NullTime var createdAt time.Time err = a.DB.QueryRow(` SELECT si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, si.group_name, si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, si.last_completed_at, si.created_at FROM shopping_items si WHERE si.id = $1 AND si.deleted = FALSE `, itemID).Scan( &item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &description, &groupName, &item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed, &lastCompletedAt, &createdAt, ) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error getting shopping item: %v", err) sendErrorWithCORS(w, "Error getting item", http.StatusInternalServerError) return } // Проверяем доступ через доску var ownerID int a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, item.BoardID).Scan(&ownerID) if ownerID != userID { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, item.BoardID, userID).Scan(&isMember) if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } } if description.Valid { item.Description = &description.String } if groupName.Valid { item.GroupName = &groupName.String } if repetitionPeriod.Valid { item.RepetitionPeriod = &repetitionPeriod.String } if nextShowAt.Valid { s := nextShowAt.Time.Format(time.RFC3339) item.NextShowAt = &s } if lastCompletedAt.Valid { s := lastCompletedAt.Time.Format(time.RFC3339) item.LastCompletedAt = &s } item.CreatedAt = createdAt.Format(time.RFC3339) // Получаем последний купленный объём из истории var lastVolume sql.NullFloat64 a.DB.QueryRow(` SELECT volume FROM shopping_item_history WHERE item_id = $1 ORDER BY completed_at DESC LIMIT 1 `, itemID).Scan(&lastVolume) if lastVolume.Valid { item.LastVolume = &lastVolume.Float64 } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(item) } // updateShoppingItemHandler обновляет товар func (a *App) updateShoppingItemHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) return } // Проверяем что товар существует и получаем board_id var boardID int err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Item not found", http.StatusNotFound) return } // Проверяем доступ var ownerID int a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) if ownerID != userID { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } } var req ShoppingItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } if strings.TrimSpace(req.Name) == "" { sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) return } volumeBase := 1.0 if req.VolumeBase != nil && *req.VolumeBase > 0 { volumeBase = *req.VolumeBase } _, err = a.DB.Exec(` UPDATE shopping_items SET name = $1, description = $2, group_name = $3, volume_base = $4, repetition_period = $5::interval, updated_at = NOW() WHERE id = $6 `, strings.TrimSpace(req.Name), req.Description, req.GroupName, volumeBase, req.RepetitionPeriod, itemID) if err != nil { log.Printf("Error updating shopping item: %v", err) sendErrorWithCORS(w, "Error updating item", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item updated successfully", }) } // deleteShoppingItemHandler удаляет товар func (a *App) deleteShoppingItemHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) return } var boardID int err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Item not found", http.StatusNotFound) return } var ownerID int a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) if ownerID != userID { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } } _, err = a.DB.Exec(`UPDATE shopping_items SET deleted = TRUE, updated_at = NOW() WHERE id = $1`, itemID) if err != nil { log.Printf("Error deleting shopping item: %v", err) sendErrorWithCORS(w, "Error deleting item", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // completeShoppingItemHandler выполняет товар (покупку) func (a *App) completeShoppingItemHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) return } var req CompleteShoppingItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Получаем товар var boardID int var volumeBase float64 var repetitionPeriod sql.NullString var itemName string err = a.DB.QueryRow(` SELECT board_id, volume_base, repetition_period::text, name FROM shopping_items WHERE id = $1 AND deleted = FALSE `, itemID).Scan(&boardID, &volumeBase, &repetitionPeriod, &itemName) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error getting shopping item for complete: %v", err) sendErrorWithCORS(w, "Error completing item", http.StatusInternalServerError) return } // Проверяем доступ var ownerID int a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) if ownerID != userID { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } } actualVolume := volumeBase if req.Volume != nil && *req.Volume > 0 { actualVolume = *req.Volume } now := time.Now() if repetitionPeriod.Valid && repetitionPeriod.String != "" { // Рассчитываем next_show_at с учётом объёма multiplier := actualVolume / volumeBase baseNext := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, now) if baseNext != nil { // Применяем множитель: сдвигаем пропорционально объёму baseDuration := baseNext.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())) adjustedDuration := time.Duration(float64(baseDuration) * multiplier) nextShowAt := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Add(adjustedDuration) _, err = a.DB.Exec(` UPDATE shopping_items SET completed = completed + 1, last_completed_at = $1, next_show_at = $2, updated_at = NOW() WHERE id = $3 `, now, nextShowAt, itemID) } else { _, err = a.DB.Exec(` UPDATE shopping_items SET completed = completed + 1, last_completed_at = $1, updated_at = NOW() WHERE id = $2 `, now, itemID) } } else { // Одноразовый товар - помечаем удалённым _, err = a.DB.Exec(` UPDATE shopping_items SET completed = completed + 1, last_completed_at = $1, deleted = TRUE, updated_at = NOW() WHERE id = $2 `, now, itemID) } if err != nil { log.Printf("Error completing shopping item: %v", err) sendErrorWithCORS(w, "Error completing item", http.StatusInternalServerError) return } // Записываем в историю покупок _, histErr := a.DB.Exec(` INSERT INTO shopping_item_history (item_id, user_id, name, volume, completed_at) VALUES ($1, $2, $3, $4, $5) `, itemID, userID, itemName, actualVolume, now) if histErr != nil { log.Printf("Error inserting shopping item history: %v", histErr) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item completed successfully", }) } // postponeShoppingItemHandler переносит товар на указанную дату func (a *App) postponeShoppingItemHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) return } var req PostponeTaskRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) return } // Проверяем что товар существует и получаем board_id var boardID int err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1 AND deleted = FALSE`, itemID).Scan(&boardID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Item not found", http.StatusNotFound) return } // Проверяем доступ var ownerID int a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) if ownerID != userID { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } } var nextShowAtValue interface{} if req.NextShowAt == nil || *req.NextShowAt == "" { nextShowAtValue = nil } else { nextShowAt, err := time.Parse(time.RFC3339, *req.NextShowAt) if err != nil { sendErrorWithCORS(w, "Invalid date format. Use RFC3339 format", http.StatusBadRequest) return } nextShowAtValue = nextShowAt } _, err = a.DB.Exec(` UPDATE shopping_items SET next_show_at = $1, updated_at = NOW() WHERE id = $2 `, nextShowAtValue, itemID) if err != nil { log.Printf("Error postponing shopping item: %v", err) sendErrorWithCORS(w, "Error postponing item", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, "message": "Item postponed successfully", }) } // getShoppingGroupSuggestionsHandler возвращает уникальные группы товаров для автодополнения func (a *App) getShoppingGroupSuggestionsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } groups := []string{} rows, err := a.DB.Query(` SELECT DISTINCT si.group_name FROM shopping_items si JOIN shopping_boards sb ON si.board_id = sb.id LEFT JOIN shopping_board_members sbm ON sb.id = sbm.board_id WHERE si.group_name IS NOT NULL AND si.group_name != '' AND si.deleted = FALSE AND sb.deleted = FALSE AND (sb.owner_id = $1 OR sbm.user_id = $1) ORDER BY si.group_name `, userID) if err != nil { log.Printf("Error getting shopping group suggestions: %v", err) sendErrorWithCORS(w, "Error getting groups", http.StatusInternalServerError) return } defer rows.Close() for rows.Next() { var groupName string if err := rows.Scan(&groupName); err != nil { log.Printf("Error scanning shopping group name: %v", err) continue } groups = append(groups, groupName) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(groups) } // getShoppingItemHistoryHandler возвращает последние 10 покупок товара func (a *App) getShoppingItemHistoryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) itemID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid item ID", http.StatusBadRequest) return } // Получаем board_id товара для проверки доступа var boardID int err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1`, itemID).Scan(&boardID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Item not found", http.StatusNotFound) return } if err != nil { log.Printf("Error getting shopping item for history: %v", err) sendErrorWithCORS(w, "Error getting history", http.StatusInternalServerError) return } // Проверяем доступ var ownerID int a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) if ownerID != userID { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } } rows, err := a.DB.Query(` SELECT id, name, volume, completed_at FROM shopping_item_history WHERE item_id = $1 ORDER BY completed_at DESC LIMIT 10 `, itemID) if err != nil { log.Printf("Error getting shopping item history: %v", err) sendErrorWithCORS(w, "Error getting history", http.StatusInternalServerError) return } defer rows.Close() type HistoryEntry struct { ID int `json:"id"` Name string `json:"name"` Volume float64 `json:"volume"` CompletedAt string `json:"completed_at"` } history := []HistoryEntry{} for rows.Next() { var entry HistoryEntry var completedAt time.Time if err := rows.Scan(&entry.ID, &entry.Name, &entry.Volume, &completedAt); err != nil { log.Printf("Error scanning shopping item history: %v", err) continue } entry.CompletedAt = completedAt.Format(time.RFC3339) history = append(history, entry) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(history) } // deleteShoppingItemHistoryHandler удаляет запись из истории покупок func (a *App) deleteShoppingItemHistoryHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) historyID, err := strconv.Atoi(vars["id"]) if err != nil { sendErrorWithCORS(w, "Invalid history ID", http.StatusBadRequest) return } // Получаем item_id из записи истории для проверки доступа var itemID int err = a.DB.QueryRow(`SELECT item_id FROM shopping_item_history WHERE id = $1`, historyID).Scan(&itemID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "History entry not found", http.StatusNotFound) return } if err != nil { log.Printf("Error getting history entry: %v", err) sendErrorWithCORS(w, "Error deleting history entry", http.StatusInternalServerError) return } // Получаем board_id товара для проверки доступа var boardID int err = a.DB.QueryRow(`SELECT board_id FROM shopping_items WHERE id = $1`, itemID).Scan(&boardID) if err != nil { log.Printf("Error getting shopping item for history delete: %v", err) sendErrorWithCORS(w, "Error deleting history entry", http.StatusInternalServerError) return } // Проверяем доступ var ownerID int a.DB.QueryRow(`SELECT owner_id FROM shopping_boards WHERE id = $1`, boardID).Scan(&ownerID) if ownerID != userID { var isMember bool a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM shopping_board_members WHERE board_id = $1 AND user_id = $2)`, boardID, userID).Scan(&isMember) if !isMember { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } } _, err = a.DB.Exec(`DELETE FROM shopping_item_history WHERE id = $1`, historyID) if err != nil { log.Printf("Error deleting shopping item history: %v", err) sendErrorWithCORS(w, "Error deleting history entry", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, }) } // getPurchaseBoardsInfoHandler возвращает доски пользователя с их группами для формы закупок func (a *App) getPurchaseBoardsInfoHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } // Получаем все доски пользователя (свои и расшаренные) boardRows, err := a.DB.Query(` SELECT sb.id, sb.name FROM shopping_boards sb WHERE sb.deleted = FALSE AND ( sb.owner_id = $1 OR EXISTS (SELECT 1 FROM shopping_board_members sbm WHERE sbm.board_id = sb.id AND sbm.user_id = $1) ) ORDER BY sb.name `, userID) if err != nil { log.Printf("Error getting boards for purchase config: %v", err) sendErrorWithCORS(w, "Error getting boards", http.StatusInternalServerError) return } defer boardRows.Close() type BoardInfo struct { ID int `json:"id"` Name string `json:"name"` Groups []string `json:"groups"` } boards := make([]BoardInfo, 0) boardIDs := make([]int, 0) for boardRows.Next() { var board BoardInfo if err := boardRows.Scan(&board.ID, &board.Name); err != nil { log.Printf("Error scanning board: %v", err) continue } board.Groups = make([]string, 0) boards = append(boards, board) boardIDs = append(boardIDs, board.ID) } // Получаем группы для каждой доски for i, boardID := range boardIDs { groupRows, err := a.DB.Query(` SELECT DISTINCT group_name FROM shopping_items WHERE board_id = $1 AND deleted = FALSE AND group_name IS NOT NULL AND group_name != '' ORDER BY group_name `, boardID) if err != nil { log.Printf("Error getting groups for board %d: %v", boardID, err) continue } for groupRows.Next() { var groupName string if err := groupRows.Scan(&groupName); err == nil { boards[i].Groups = append(boards[i].Groups, groupName) } } groupRows.Close() // Проверяем наличие товаров без группы var hasUngrouped bool a.DB.QueryRow(` SELECT EXISTS(SELECT 1 FROM shopping_items WHERE board_id = $1 AND deleted = FALSE AND (group_name IS NULL OR group_name = '')) `, boardID).Scan(&hasUngrouped) if hasUngrouped { boards[i].Groups = append(boards[i].Groups, "") } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "boards": boards, }) } // getPurchaseItemsHandler возвращает товары для конфигурации закупки func (a *App) getPurchaseItemsHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) return } setCORSHeaders(w) userID, ok := getUserIDFromContext(r) if !ok { sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) return } vars := mux.Vars(r) purchaseConfigID, err := strconv.Atoi(vars["purchaseConfigId"]) if err != nil { sendErrorWithCORS(w, "Invalid purchase config ID", http.StatusBadRequest) return } // Проверяем что конфиг принадлежит пользователю var configUserID int err = a.DB.QueryRow("SELECT user_id FROM purchase_configs WHERE id = $1", purchaseConfigID).Scan(&configUserID) if err == sql.ErrNoRows { sendErrorWithCORS(w, "Purchase config not found", http.StatusNotFound) return } if err != nil { sendErrorWithCORS(w, "Error checking purchase config", http.StatusInternalServerError) return } if configUserID != userID { sendErrorWithCORS(w, "Access denied", http.StatusForbidden) return } // Получаем связанные доски и группы boardRows, err := a.DB.Query(` SELECT board_id, group_name FROM purchase_config_boards WHERE purchase_config_id = $1 `, purchaseConfigID) if err != nil { sendErrorWithCORS(w, "Error getting purchase config boards", http.StatusInternalServerError) return } defer boardRows.Close() type boardFilter struct { BoardID int GroupName *string } filters := make([]boardFilter, 0) for boardRows.Next() { var f boardFilter var groupName sql.NullString if err := boardRows.Scan(&f.BoardID, &groupName); err == nil { if groupName.Valid { f.GroupName = &groupName.String } filters = append(filters, f) } } if len(filters) == 0 { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode([]ShoppingItem{}) return } // Строим динамический запрос для получения товаров items := make([]ShoppingItem, 0) for _, f := range filters { var query string var args []interface{} if f.GroupName != nil { if *f.GroupName == "" { // Пустая строка означает "товары без группы" query = ` SELECT si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, si.group_name, si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, si.last_completed_at, si.created_at FROM shopping_items si WHERE si.board_id = $1 AND si.deleted = FALSE AND (si.group_name IS NULL OR si.group_name = '') ORDER BY si.created_at ASC ` args = []interface{}{f.BoardID} } else { query = ` SELECT si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, si.group_name, si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, si.last_completed_at, si.created_at FROM shopping_items si WHERE si.board_id = $1 AND si.deleted = FALSE AND si.group_name = $2 ORDER BY si.created_at ASC ` args = []interface{}{f.BoardID, *f.GroupName} } } else { query = ` SELECT si.id, si.user_id, si.board_id, si.author_id, si.name, si.description, si.group_name, si.volume_base, si.repetition_period::text, si.next_show_at, si.completed, si.last_completed_at, si.created_at FROM shopping_items si WHERE si.board_id = $1 AND si.deleted = FALSE ORDER BY si.created_at ASC ` args = []interface{}{f.BoardID} } rows, err := a.DB.Query(query, args...) if err != nil { log.Printf("Error getting purchase items for board %d: %v", f.BoardID, err) continue } for rows.Next() { var item ShoppingItem var description sql.NullString var groupName sql.NullString var repetitionPeriod sql.NullString var nextShowAt sql.NullTime var lastCompletedAt sql.NullTime var createdAt time.Time err := rows.Scan( &item.ID, &item.UserID, &item.BoardID, &item.AuthorID, &item.Name, &description, &groupName, &item.VolumeBase, &repetitionPeriod, &nextShowAt, &item.Completed, &lastCompletedAt, &createdAt, ) if err != nil { log.Printf("Error scanning purchase item: %v", err) continue } if description.Valid { item.Description = &description.String } if groupName.Valid { item.GroupName = &groupName.String } if repetitionPeriod.Valid { item.RepetitionPeriod = &repetitionPeriod.String } if nextShowAt.Valid { s := nextShowAt.Time.Format(time.RFC3339) item.NextShowAt = &s } if lastCompletedAt.Valid { s := lastCompletedAt.Time.Format(time.RFC3339) item.LastCompletedAt = &s } item.CreatedAt = createdAt.Format(time.RFC3339) items = append(items, item) } rows.Close() } // Дедупликация (товар может попасть из нескольких фильтров) seen := make(map[int]bool) uniqueItems := make([]ShoppingItem, 0, len(items)) for _, item := range items { if !seen[item.ID] { seen[item.ID] = true uniqueItems = append(uniqueItems, item) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(uniqueItems) }