2025-12-29 20:01:55 +03:00
package main
import (
"bytes"
2026-01-22 19:47:50 +03:00
"compress/gzip"
2026-01-01 18:21:18 +03:00
"context"
"crypto/rand"
2025-12-29 20:01:55 +03:00
"database/sql"
2026-01-01 18:21:18 +03:00
"encoding/base64"
2026-01-13 20:55:44 +03:00
"encoding/hex"
2025-12-29 20:01:55 +03:00
"encoding/json"
"fmt"
2026-01-12 17:42:51 +03:00
"html"
2025-12-29 20:01:55 +03:00
"io"
"log"
"math"
"net/http"
2026-01-02 15:34:01 +03:00
"net/url"
2025-12-29 20:01:55 +03:00
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf16"
2026-01-26 18:45:58 +03:00
"image/jpeg"
2026-01-22 20:11:29 +03:00
"github.com/chromedp/chromedp"
2026-01-11 21:12:26 +03:00
"github.com/disintegration/imaging"
2026-01-26 18:45:58 +03:00
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
2026-01-01 18:21:18 +03:00
"github.com/golang-jwt/jwt/v5"
2026-01-25 16:41:50 +03:00
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
2025-12-29 20:01:55 +03:00
"github.com/gorilla/mux"
"github.com/joho/godotenv"
2026-01-06 14:54:37 +03:00
"github.com/lib/pq"
2026-01-26 18:45:58 +03:00
_ "github.com/lib/pq"
2025-12-29 20:01:55 +03:00
"github.com/robfig/cron/v3"
2026-01-01 18:21:18 +03:00
"golang.org/x/crypto/bcrypt"
2025-12-29 20:01:55 +03:00
)
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 {
2026-01-26 18:45:58 +03:00
Words [ ] TestProgressUpdate ` json:"words" `
ConfigID * int ` json:"config_id,omitempty" `
2025-12-29 20:01:55 +03:00
}
type Config struct {
2026-01-13 18:22:02 +03:00
ID int ` json:"id" `
WordsCount int ` json:"words_count" `
MaxCards * int ` json:"max_cards,omitempty" `
2025-12-29 20:01:55 +03:00
}
type ConfigRequest struct {
2026-01-13 18:22:02 +03:00
WordsCount int ` json:"words_count" `
MaxCards * int ` json:"max_cards,omitempty" `
DictionaryIDs [ ] int ` json:"dictionary_ids,omitempty" `
2025-12-29 20:01:55 +03:00
}
type Dictionary struct {
2026-01-26 18:45:58 +03:00
ID int ` json:"id" `
2025-12-29 20:01:55 +03:00
Name string ` json:"name" `
2026-01-26 18:45:58 +03:00
WordsCount int ` json:"wordsCount" `
2025-12-29 20:01:55 +03:00
}
type DictionaryRequest struct {
Name string ` json:"name" `
}
type TestConfigsAndDictionariesResponse struct {
2026-01-26 18:45:58 +03:00
Configs [ ] Config ` json:"configs" `
2025-12-29 20:01:55 +03:00
Dictionaries [ ] Dictionary ` json:"dictionaries" `
}
type WeeklyProjectStats struct {
ProjectName string ` json:"project_name" `
TotalScore float64 ` json:"total_score" `
MinGoalScore float64 ` json:"min_goal_score" `
MaxGoalScore * float64 ` json:"max_goal_score,omitempty" `
Priority * int ` json:"priority,omitempty" `
CalculatedScore float64 ` json:"calculated_score" `
2026-02-04 15:04:58 +03:00
TodayChange * float64 ` json:"today_change,omitempty" `
2025-12-29 20:01:55 +03:00
}
2026-01-21 20:01:24 +03:00
type GroupsProgress struct {
Group1 * float64 ` json:"group1,omitempty" `
Group2 * float64 ` json:"group2,omitempty" `
Group0 * float64 ` json:"group0,omitempty" `
}
2025-12-29 20:01:55 +03:00
type WeeklyStatsResponse struct {
2026-01-21 20:01:24 +03:00
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" `
2025-12-29 20:01:55 +03:00
}
type MessagePostRequest struct {
Body struct {
Text string ` json:"text" `
} ` json:"body" `
}
type ProcessedNode struct {
Project string ` json:"project" `
Score float64 ` json:"score" `
}
type ProcessedEntry struct {
Text string ` json:"text" `
CreatedDate string ` json:"createdDate" `
Nodes [ ] ProcessedNode ` json:"nodes" `
Raw string ` json:"raw" `
Markdown string ` json:"markdown" `
}
type WeeklyGoalSetup struct {
ProjectName string ` json:"project_name" `
MinGoalScore float64 ` json:"min_goal_score" `
MaxGoalScore float64 ` json:"max_goal_score" `
}
type Project struct {
2026-01-26 18:45:58 +03:00
ProjectID int ` json:"project_id" `
ProjectName string ` json:"project_name" `
Priority * int ` json:"priority,omitempty" `
2025-12-29 20:01:55 +03:00
}
type ProjectPriorityUpdate struct {
2026-01-26 18:45:58 +03:00
ID int ` json:"id" `
Priority * int ` json:"priority" `
2025-12-29 20:01:55 +03:00
}
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" `
}
2026-02-03 18:26:21 +03:00
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" `
}
2025-12-29 20:01:55 +03:00
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" `
}
2025-12-31 19:11:28 +03:00
type TelegramChat struct {
ID int64 ` json:"id" `
}
2026-01-02 14:47:51 +03:00
type TelegramUser struct {
ID int64 ` json:"id" `
}
2025-12-29 20:01:55 +03:00
type TelegramMessage struct {
Text string ` json:"text" `
Entities [ ] TelegramEntity ` json:"entities" `
2025-12-31 19:11:28 +03:00
Chat TelegramChat ` json:"chat" `
2026-01-02 14:47:51 +03:00
From * TelegramUser ` json:"from,omitempty" `
2025-12-29 20:01:55 +03:00
}
type TelegramWebhook struct {
Message TelegramMessage ` json:"message" `
}
// TelegramUpdate - структура для Telegram webhook (обычно это Update объект)
type TelegramUpdate struct {
2026-01-01 18:21:18 +03:00
UpdateID int ` json:"update_id" `
Message * TelegramMessage ` json:"message,omitempty" `
2025-12-31 19:39:01 +03:00
EditedMessage * TelegramMessage ` json:"edited_message,omitempty" `
2025-12-29 20:01:55 +03:00
}
2026-01-04 19:42:29 +03:00
// Task structures
type Task struct {
2026-01-26 18:45:58 +03:00
ID int ` json:"id" `
Name string ` json:"name" `
Completed int ` json:"completed" `
LastCompletedAt * string ` json:"last_completed_at,omitempty" `
NextShowAt * string ` json:"next_show_at,omitempty" `
RewardMessage * string ` json:"reward_message,omitempty" `
ProgressionBase * float64 ` json:"progression_base,omitempty" `
RepetitionPeriod * string ` json:"repetition_period,omitempty" `
RepetitionDate * string ` json:"repetition_date,omitempty" `
WishlistID * int ` json:"wishlist_id,omitempty" `
ConfigID * int ` json:"config_id,omitempty" `
RewardPolicy * string ` json:"reward_policy,omitempty" ` // "personal" или "general" для задач, связанных с желаниями
2026-01-06 14:54:37 +03:00
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
2026-01-26 18:45:58 +03:00
ProjectNames [ ] string ` json:"project_names" `
SubtasksCount int ` json:"subtasks_count" `
HasProgression bool ` json:"has_progression" `
2026-01-29 17:47:47 +03:00
AutoComplete bool ` json:"auto_complete" `
2026-01-04 19:42:29 +03:00
}
type Reward struct {
2026-01-26 18:45:58 +03:00
ID int ` json:"id" `
Position int ` json:"position" `
ProjectName string ` json:"project_name" `
Value float64 ` json:"value" `
UseProgression bool ` json:"use_progression" `
2026-01-04 19:42:29 +03:00
}
type Subtask struct {
2026-01-26 18:45:58 +03:00
Task Task ` json:"task" `
2026-01-04 19:42:29 +03:00
Rewards [ ] Reward ` json:"rewards" `
}
2026-01-25 15:28:37 +03:00
type WishlistInfo struct {
ID int ` json:"id" `
Name string ` json:"name" `
Unlocked bool ` json:"unlocked" `
}
2026-01-04 19:42:29 +03:00
type TaskDetail struct {
2026-01-26 18:45:58 +03:00
Task Task ` json:"task" `
Rewards [ ] Reward ` json:"rewards" `
Subtasks [ ] Subtask ` json:"subtasks" `
WishlistInfo * WishlistInfo ` json:"wishlist_info,omitempty" `
2026-01-13 18:22:02 +03:00
// 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" `
2026-01-28 20:19:53 +03:00
// Draft fields (only present if draft exists)
DraftProgressionValue * float64 ` json:"draft_progression_value,omitempty" `
DraftSubtasks [ ] DraftSubtask ` json:"draft_subtasks,omitempty" `
2026-01-04 19:42:29 +03:00
}
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" `
Rewards [ ] RewardRequest ` json:"rewards,omitempty" `
}
type TaskRequest struct {
2026-01-26 18:45:58 +03:00
Name string ` json:"name" `
ProgressionBase * float64 ` json:"progression_base,omitempty" `
RewardMessage * string ` json:"reward_message,omitempty" `
RepetitionPeriod * string ` json:"repetition_period,omitempty" `
RepetitionDate * string ` json:"repetition_date,omitempty" `
WishlistID * int ` json:"wishlist_id,omitempty" `
RewardPolicy * string ` json:"reward_policy,omitempty" ` // "personal" или "general" для задач, связанных с желаниями
Rewards [ ] RewardRequest ` json:"rewards,omitempty" `
Subtasks [ ] SubtaskRequest ` json:"subtasks,omitempty" `
2026-01-13 18:22:02 +03:00
// 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" `
2026-01-04 19:42:29 +03:00
}
type CompleteTaskRequest struct {
Value * float64 ` json:"value,omitempty" `
ChildrenTaskIDs [ ] int ` json:"children_task_ids,omitempty" `
}
2026-01-06 15:56:52 +03:00
type PostponeTaskRequest struct {
NextShowAt * string ` json:"next_show_at" `
}
2026-01-28 20:19:53 +03:00
// ============================================
// Task Draft structures
// ============================================
type SaveDraftRequest struct {
ProgressionValue * float64 ` json:"progression_value,omitempty" `
ChildrenTaskIDs [ ] int ` json:"children_task_ids,omitempty" ` // только checked подзадачи
AutoComplete bool ` json:"auto_complete" `
}
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" `
}
2026-01-11 21:12:26 +03:00
// ============================================
// Wishlist structures
// ============================================
2026-01-12 18:58:52 +03:00
type LinkedTask struct {
ID int ` json:"id" `
Name string ` json:"name" `
Completed int ` json:"completed" `
NextShowAt * string ` json:"next_show_at,omitempty" `
2026-01-13 22:35:01 +03:00
UserID * int ` json:"user_id,omitempty" ` // ID пользователя-владельца задачи
2026-01-12 18:58:52 +03:00
}
2026-01-11 21:12:26 +03:00
type WishlistItem struct {
2026-01-31 18:43:25 +03:00
ID int ` json:"id" `
Name string ` json:"name" `
Price * float64 ` json:"price,omitempty" `
ImageURL * string ` json:"image_url,omitempty" `
Link * string ` json:"link,omitempty" `
Unlocked bool ` json:"unlocked" `
Completed bool ` json:"completed" `
FirstLockedCondition * UnlockConditionDisplay ` json:"first_locked_condition,omitempty" `
MoreLockedConditions int ` json:"more_locked_conditions,omitempty" `
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" ` // Количество задач для этого желания
2026-02-04 15:46:05 +03:00
ProjectID * int ` json:"project_id,omitempty" ` // ID проекта, к которому принадлежит желание
ProjectName * string ` json:"project_name,omitempty" ` // Название проекта
2026-01-11 21:12:26 +03:00
}
type UnlockConditionDisplay struct {
ID int ` json:"id" `
Type string ` json:"type" `
2026-01-26 18:45:58 +03:00
TaskID * int ` json:"task_id,omitempty" ` // ID задачи (для task_completion)
2026-01-11 21:12:26 +03:00
TaskName * string ` json:"task_name,omitempty" `
2026-01-26 18:45:58 +03:00
ProjectID * int ` json:"project_id,omitempty" ` // ID проекта (для project_points)
2026-01-11 21:12:26 +03:00
ProjectName * string ` json:"project_name,omitempty" `
RequiredPoints * float64 ` json:"required_points,omitempty" `
2026-01-12 17:02:33 +03:00
StartDate * string ` json:"start_date,omitempty" ` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время
2026-01-11 21:12:26 +03:00
DisplayOrder int ` json:"display_order" `
// Прогресс выполнения
2026-01-26 18:45:58 +03:00
CurrentPoints * float64 ` json:"current_points,omitempty" ` // Текущее количество баллов (для project_points)
TaskCompleted * bool ` json:"task_completed,omitempty" ` // Выполнена ли задача (для task_completion)
2026-01-13 22:35:01 +03:00
// Персональные цели
2026-01-26 18:45:58 +03:00
UserID * int ` json:"user_id,omitempty" ` // ID пользователя для персональных целей
UserName * string ` json:"user_name,omitempty" ` // Имя пользователя для персональных целей
2026-01-31 18:43:25 +03:00
// Срок разблокировки
WeeksText * string ` json:"weeks_text,omitempty" ` // Отформатированный текст срока разблокировки
2026-01-11 21:12:26 +03:00
}
type WishlistRequest struct {
Name string ` json:"name" `
Price * float64 ` json:"price,omitempty" `
Link * string ` json:"link,omitempty" `
2026-02-04 15:46:05 +03:00
ProjectID * int ` json:"project_id,omitempty" ` // ID проекта, к которому принадлежит желание
2026-01-11 21:12:26 +03:00
UnlockConditions [ ] UnlockConditionRequest ` json:"unlock_conditions,omitempty" `
}
type UnlockConditionRequest struct {
2026-01-26 18:45:58 +03:00
ID * int ` json:"id,omitempty" ` // ID существующего условия (для сохранения чужих условий)
2026-01-11 21:12:26 +03:00
Type string ` json:"type" `
TaskID * int ` json:"task_id,omitempty" `
ProjectID * int ` json:"project_id,omitempty" `
RequiredPoints * float64 ` json:"required_points,omitempty" `
2026-01-12 17:02:33 +03:00
StartDate * string ` json:"start_date,omitempty" ` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время
2026-01-11 21:12:26 +03:00
DisplayOrder * int ` json:"display_order,omitempty" `
}
type WishlistResponse struct {
2026-01-26 18:45:58 +03:00
Unlocked [ ] WishlistItem ` json:"unlocked" `
Locked [ ] WishlistItem ` json:"locked" `
Completed [ ] WishlistItem ` json:"completed,omitempty" `
CompletedCount int ` json:"completed_count" ` // Количество завершённых желаний
2026-01-11 21:12:26 +03:00
}
2026-01-13 22:35:01 +03:00
// ============================================
// 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" `
}
2026-01-06 16:41:54 +03:00
// ============================================
// 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
}
2026-01-07 15:31:40 +03:00
// calculateNextShowAtFromRepetitionPeriod calculates the next show date by adding repetition_period to fromDate
2026-01-11 15:09:32 +03:00
// 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")
2026-01-07 15:31:40 +03:00
func calculateNextShowAtFromRepetitionPeriod ( repetitionPeriod string , fromDate time . Time ) * time . Time {
if repetitionPeriod == "" {
return nil
}
parts := strings . Fields ( strings . TrimSpace ( repetitionPeriod ) )
if len ( parts ) < 2 {
2026-01-11 15:09:32 +03:00
log . Printf ( "calculateNextShowAtFromRepetitionPeriod: invalid format, parts=%v" , parts )
2026-01-07 15:31:40 +03:00
return nil
}
value , err := strconv . Atoi ( parts [ 0 ] )
if err != nil {
2026-01-11 15:09:32 +03:00
log . Printf ( "calculateNextShowAtFromRepetitionPeriod: failed to parse value '%s': %v" , parts [ 0 ] , err )
2026-01-07 15:31:40 +03:00
return nil
}
unit := strings . ToLower ( parts [ 1 ] )
2026-01-11 15:09:32 +03:00
log . Printf ( "calculateNextShowAtFromRepetitionPeriod: value=%d, unit='%s'" , value , unit )
2026-01-07 15:31:40 +03:00
// Start from fromDate at midnight
nextDate := time . Date ( fromDate . Year ( ) , fromDate . Month ( ) , fromDate . Day ( ) , 0 , 0 , 0 , 0 , fromDate . Location ( ) )
switch unit {
2026-01-11 15:09:32 +03:00
case "minute" , "minutes" , "mins" , "min" :
2026-01-07 15:31:40 +03:00
nextDate = nextDate . Add ( time . Duration ( value ) * time . Minute )
2026-01-11 15:09:32 +03:00
case "hour" , "hours" , "hrs" , "hr" :
2026-01-07 15:31:40 +03:00
nextDate = nextDate . Add ( time . Duration ( value ) * time . Hour )
case "day" , "days" :
2026-01-11 15:09:32 +03:00
// 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" :
2026-01-07 15:31:40 +03:00
nextDate = nextDate . AddDate ( 0 , 0 , value * 7 )
2026-01-11 15:09:32 +03:00
case "month" , "months" , "mons" , "mon" :
2026-01-07 15:31:40 +03:00
nextDate = nextDate . AddDate ( 0 , value , 0 )
2026-01-11 15:09:32 +03:00
log . Printf ( "calculateNextShowAtFromRepetitionPeriod: added %d months, result=%v" , value , nextDate )
case "year" , "years" , "yrs" , "yr" :
2026-01-07 15:31:40 +03:00
nextDate = nextDate . AddDate ( value , 0 , 0 )
default :
2026-01-11 15:09:32 +03:00
log . Printf ( "calculateNextShowAtFromRepetitionPeriod: unknown unit '%s'" , unit )
2026-01-07 15:31:40 +03:00
return nil
}
return & nextDate
}
2026-01-01 18:21:18 +03:00
// ============================================
// 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" `
2026-02-02 19:16:49 +03:00
IsAdmin bool ` json:"is_admin" `
2026-01-01 18:21:18 +03:00
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"
2025-12-29 20:01:55 +03:00
type App struct {
2026-01-02 14:47:51 +03:00
DB * sql . DB
webhookMutex sync . Mutex
lastWebhookTime map [ int ] time . Time // config_id -> last webhook time
telegramBot * tgbotapi . BotAPI
telegramBotUsername string
jwtSecret [ ] byte
2025-12-29 20:01:55 +03:00
}
func setCORSHeaders ( w http . ResponseWriter ) {
w . Header ( ) . Set ( "Access-Control-Allow-Origin" , "*" )
w . Header ( ) . Set ( "Access-Control-Allow-Methods" , "GET, POST, PUT, DELETE, OPTIONS" )
2026-01-01 18:21:18 +03:00
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
}
2026-01-01 18:38:28 +03:00
// 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
}
2026-01-01 18:21:18 +03:00
func ( a * App ) generateAccessToken ( userID int ) ( string , error ) {
claims := JWTClaims {
UserID : userID ,
RegisteredClaims : jwt . RegisteredClaims {
2026-01-02 16:00:54 +03:00
ExpiresAt : jwt . NewNumericDate ( time . Now ( ) . Add ( 24 * time . Hour ) ) ,
2026-01-01 18:21:18 +03:00
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 ) )
} )
}
2026-02-02 19:16:49 +03:00
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 )
} )
}
2026-01-01 18:21:18 +03:00
// ============================================
// 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 ( `
2026-02-02 19:16:49 +03:00
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
2026-01-01 18:21:18 +03:00
` , req . Email , passwordHash , req . Name ) . Scan (
2026-02-02 19:16:49 +03:00
& user . ID , & user . Email , & user . Name , & user . CreatedAt , & user . UpdatedAt , & user . IsActive , & user . IsAdmin , & user . LastLoginAt ,
2026-01-01 18:21:18 +03:00
)
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 )
2026-01-02 16:00:54 +03:00
` , user . ID , refreshTokenHash , nil )
2026-01-01 18:21:18 +03:00
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 ,
2026-01-02 16:00:54 +03:00
ExpiresIn : 86400 , // 24 hours
2026-01-01 18:21:18 +03:00
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 ( `
2026-02-02 19:16:49 +03:00
SELECT id , email , password_hash , name , created_at , updated_at , is_active , is_admin , last_login_at
2026-01-01 18:21:18 +03:00
FROM users WHERE email = $ 1
` , req . Email ) . Scan (
& user . ID , & user . Email , & user . PasswordHash , & user . Name ,
2026-02-02 19:16:49 +03:00
& user . CreatedAt , & user . UpdatedAt , & user . IsActive , & user . IsAdmin , & user . LastLoginAt ,
2026-01-01 18:21:18 +03:00
)
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 )
2026-01-02 16:00:54 +03:00
` , user . ID , refreshTokenHash , nil )
2026-01-01 18:21:18 +03:00
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 ,
2026-01-02 16:00:54 +03:00
ExpiresIn : 86400 , // 24 hours
2026-01-01 18:21:18 +03:00
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
}
2026-01-02 16:00:54 +03:00
// Find valid refresh token (expires_at is NULL for tokens without expiration)
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( `
2026-02-02 19:16:49 +03:00
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
2026-01-01 18:21:18 +03:00
FROM refresh_tokens rt
JOIN users u ON rt . user_id = u . id
2026-01-02 16:00:54 +03:00
WHERE rt . expires_at IS NULL OR rt . expires_at > NOW ( )
2026-01-01 18:21:18 +03:00
` )
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 ,
2026-02-02 19:16:49 +03:00
& user . CreatedAt , & user . UpdatedAt , & user . IsActive , & user . IsAdmin , & user . LastLoginAt )
2026-01-01 18:21:18 +03:00
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
}
2026-01-25 15:46:24 +03:00
// Generate new tokens FIRST before deleting old one to prevent race condition
2026-01-01 18:21:18 +03:00
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
}
2026-01-25 15:46:24 +03:00
// Store new refresh token FIRST
2026-01-01 18:21:18 +03:00
refreshTokenHash , _ := hashPassword ( refreshToken )
2026-01-25 15:46:24 +03:00
_ , err = a . DB . Exec ( `
2026-01-01 18:21:18 +03:00
INSERT INTO refresh_tokens ( user_id , token_hash , expires_at )
VALUES ( $ 1 , $ 2 , $ 3 )
2026-01-02 16:00:54 +03:00
` , user . ID , refreshTokenHash , nil )
2026-01-25 15:46:24 +03:00
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 )
2026-01-01 18:21:18 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( TokenResponse {
AccessToken : accessToken ,
RefreshToken : refreshToken ,
2026-01-02 16:00:54 +03:00
ExpiresIn : 86400 , // 24 hours
2026-01-01 18:21:18 +03:00
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 ( `
2026-02-02 19:16:49 +03:00
SELECT id , email , name , created_at , updated_at , is_active , is_admin , last_login_at
2026-01-01 18:21:18 +03:00
FROM users WHERE id = $ 1
` , userID ) . Scan (
2026-02-02 19:16:49 +03:00
& user . ID , & user . Email , & user . Name , & user . CreatedAt , & user . UpdatedAt , & user . IsActive , & user . IsAdmin , & user . LastLoginAt ,
2026-01-01 18:21:18 +03:00
)
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 )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
if err != nil || ! columnExists {
log . Printf ( "Skipping %s: user_id column does not exist (run migrations as table owner)" , table )
continue
}
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
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 )
}
}
}
2025-12-29 20:01:55 +03:00
}
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" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
// 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
2026-01-01 18:21:18 +03:00
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 )
2025-12-29 20:01:55 +03:00
ORDER BY w . id
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID , dictionaryID )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
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 {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
if lastSuccess . Valid {
word . LastSuccess = & lastSuccess . String
}
if lastFailure . Valid {
word . LastFailure = & lastFailure . String
}
words = append ( words , word )
}
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
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" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
var req WordsRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-01 19:13:37 +03:00
log . Printf ( "Error decoding addWords request: %v" , err )
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
2026-01-01 19:13:37 +03:00
log . Printf ( "addWords: user_id=%d, words_count=%d" , userID , len ( req . Words ) )
2025-12-29 20:01:55 +03:00
tx , err := a . DB . Begin ( )
if err != nil {
2026-01-01 19:13:37 +03:00
log . Printf ( "Error beginning transaction: %v" , err )
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
defer tx . Rollback ( )
2026-01-01 18:21:18 +03:00
// 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
2026-01-01 19:13:37 +03:00
log . Printf ( "Creating default dictionary for user_id=%d" , userID )
2026-01-01 18:21:18 +03:00
err = tx . QueryRow ( `
INSERT INTO dictionaries ( name , user_id ) VALUES ( ' В с е слова ' , $ 1 ) RETURNING id
` , userID ) . Scan ( & defaultDictID )
if err != nil {
2026-01-01 19:13:37 +03:00
log . Printf ( "Error creating default dictionary: %v" , err )
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
2026-01-01 19:13:37 +03:00
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 )
2026-01-01 18:21:18 +03:00
}
2025-12-29 20:01:55 +03:00
stmt , err := tx . Prepare ( `
2026-01-01 19:13:37 +03:00
INSERT INTO words ( name , translation , description , dictionary_id , user_id )
2026-01-01 19:33:29 +03:00
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 )
2025-12-29 20:01:55 +03:00
RETURNING id
` )
if err != nil {
2026-01-01 19:13:37 +03:00
log . Printf ( "Error preparing insert statement: %v" , err )
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
defer stmt . Close ( )
var addedCount int
2026-01-01 19:13:37 +03:00
for i , wordReq := range req . Words {
2025-12-29 20:01:55 +03:00
var id int
2026-01-01 18:21:18 +03:00
dictionaryID := defaultDictID
2025-12-29 20:01:55 +03:00
if wordReq . DictionaryID != nil {
dictionaryID = * wordReq . DictionaryID
2026-01-01 19:13:37 +03:00
// Проверяем, что словарь принадлежит пользователю
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
}
2025-12-29 20:01:55 +03:00
}
2026-01-01 19:33:29 +03:00
err := stmt . QueryRow ( wordReq . Name , wordReq . Translation , wordReq . Description , dictionaryID , userID ) . Scan ( & id )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-01 19:13:37 +03:00
log . Printf ( "Error inserting word %d (name='%s', dict_id=%d, user_id=%d): %v" , i , wordReq . Name , dictionaryID , userID , err )
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
addedCount ++
2026-01-01 19:13:37 +03:00
log . Printf ( "Successfully added word id=%d: name='%s', dict_id=%d" , id , wordReq . Name , dictionaryID )
2025-12-29 20:01:55 +03:00
}
if err := tx . Commit ( ) ; err != nil {
2026-01-01 19:13:37 +03:00
log . Printf ( "Error committing transaction: %v" , err )
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
2026-01-01 19:13:37 +03:00
log . Printf ( "Successfully added %d words for user_id=%d" , addedCount , userID )
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"message" : fmt . Sprintf ( "Added %d words" , addedCount ) ,
"added" : addedCount ,
} )
}
func ( a * App ) getTestWordsHandler ( w http . ResponseWriter , r * http . Request ) {
log . Printf ( "getTestWordsHandler called: %s %s" , r . Method , r . URL . Path )
setCORSHeaders ( w )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
if r . Method == "OPTIONS" {
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
// 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
}
2026-01-01 18:21:18 +03:00
// Get words_count from config (verify ownership)
2025-12-29 20:01:55 +03:00
var wordsCount int
2026-01-01 18:21:18 +03:00
err = a . DB . QueryRow ( "SELECT words_count FROM configs WHERE id = $1 AND user_id = $2" , configID , userID ) . Scan ( & wordsCount )
2025-12-29 20:01:55 +03:00
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 ( )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
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)
2026-01-26 18:45:58 +03:00
group1Count := int ( float64 ( wordsCount ) * 0.3 ) // 30%
group2Count := int ( float64 ( wordsCount ) * 0.4 ) // 40%
2025-12-29 20:01:55 +03:00
// 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
`
2026-01-01 18:21:18 +03:00
baseFrom := fmt . Sprintf ( `
2025-12-29 20:01:55 +03:00
FROM words w
2026-01-01 18:21:18 +03:00
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
2025-12-29 20:01:55 +03:00
// 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 )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
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
}
2026-01-02 16:50:40 +03:00
// Group 2: sorted by (failure + 1)/(success + 1) DESC, take top 40%
2025-12-29 20:01:55 +03:00
// 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
2026-01-02 16:50:40 +03:00
( COALESCE ( p . failure , 0 ) + 1.0 ) / ( COALESCE ( p . success , 0 ) + 1.0 ) DESC ,
2025-12-29 20:01:55 +03:00
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 )
2026-01-26 18:45:58 +03:00
2026-01-02 16:50:40 +03:00
group2Args = append ( group2Args , group2Count )
2025-12-29 20:01:55 +03:00
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 )
2026-01-02 16:50:40 +03:00
for group2Rows . Next ( ) {
2025-12-29 20:01:55 +03:00
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
2026-01-26 18:45:58 +03:00
log . Printf ( "Word selection: wordsCount=%d, group1=%d, group2=%d, collected=%d, group3Needed=%d" ,
2025-12-29 20:01:55 +03:00
wordsCount , len ( group1Words ) , len ( group2Words ) , wordsCollected , group3Needed )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
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 )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
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 )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
if r . Method == "OPTIONS" {
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
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
}
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
log . Printf ( "Received %d word updates, config_id: %v, user_id: %d" , len ( req . Words ) , req . ConfigID , userID )
2025-12-29 20:01:55 +03:00
tx , err := a . DB . Begin ( )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
defer tx . Rollback ( )
2026-01-01 18:21:18 +03:00
// 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)" )
2025-12-29 20:01:55 +03:00
stmt , err := tx . Prepare ( `
2026-01-01 18:21:18 +03:00
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 )
2025-12-29 20:01:55 +03:00
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 )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// 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
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
_ , err := stmt . Exec (
wordUpdate . ID ,
2026-01-01 18:21:18 +03:00
userID ,
2025-12-29 20:01:55 +03:00
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
}
2026-01-13 18:22:02 +03:00
// 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).
2025-12-29 20:01:55 +03:00
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" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
query := `
2026-01-13 18:22:02 +03:00
SELECT id , words_count , max_cards
2025-12-29 20:01:55 +03:00
FROM configs
2026-01-01 18:21:18 +03:00
WHERE user_id = $ 1
2025-12-29 20:01:55 +03:00
ORDER BY id
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
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 {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
if maxCards . Valid {
maxCardsVal := int ( maxCards . Int64 )
config . MaxCards = & maxCardsVal
}
configs = append ( configs , config )
}
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
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" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
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
2026-01-01 18:21:18 +03:00
WHERE d . user_id = $ 1
2025-12-29 20:01:55 +03:00
GROUP BY d . id , d . name
ORDER BY d . id
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
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 {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
dictionaries = append ( dictionaries , dict )
}
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
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" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
var req DictionaryRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
if req . Name == "" {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Имя словаря обязательно" , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
var id int
err := a . DB . QueryRow ( `
2026-01-01 18:21:18 +03:00
INSERT INTO dictionaries ( name , user_id )
VALUES ( $ 1 , $ 2 )
2025-12-29 20:01:55 +03:00
RETURNING id
2026-01-01 18:21:18 +03:00
` , req . Name , userID ) . Scan ( & id )
2025-12-29 20:01:55 +03:00
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 )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
vars := mux . Vars ( r )
dictionaryID := vars [ "id" ]
var req DictionaryRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
if req . Name == "" {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Имя словаря обязательно" , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
result , err := a . DB . Exec ( `
UPDATE dictionaries
SET name = $ 1
2026-01-01 18:21:18 +03:00
WHERE id = $ 2 AND user_id = $ 3
` , req . Name , dictionaryID , userID )
2025-12-29 20:01:55 +03:00
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 )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
vars := mux . Vars ( r )
dictionaryID := vars [ "id" ]
// Prevent deletion of default dictionary (id = 0)
if dictionaryID == "0" {
2026-01-01 18:21:18 +03:00
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 )
2025-12-29 20:01:55 +03:00
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" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
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 )
2025-12-29 20:01:55 +03:00
// Get configs
configsQuery := `
2026-01-13 18:22:02 +03:00
SELECT id , words_count , max_cards
2025-12-29 20:01:55 +03:00
FROM configs
2026-01-01 18:21:18 +03:00
WHERE user_id = $ 1
2025-12-29 20:01:55 +03:00
ORDER BY id
`
2026-01-01 18:21:18 +03:00
configsRows , err := a . DB . Query ( configsQuery , userID )
2025-12-29 20:01:55 +03:00
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
2026-01-01 18:21:18 +03:00
WHERE d . user_id = $ 1
2025-12-29 20:01:55 +03:00
GROUP BY d . id , d . name
ORDER BY d . id
`
2026-01-01 18:21:18 +03:00
dictsRows , err := a . DB . Query ( dictsQuery , userID )
2025-12-29 20:01:55 +03:00
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" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
var req ConfigRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
if req . WordsCount <= 0 {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Количество слов должно быть больше 0" , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
tx , err := a . DB . Begin ( )
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
defer tx . Rollback ( )
var id int
err = tx . QueryRow ( `
2026-01-13 18:22:02 +03:00
INSERT INTO configs ( words_count , max_cards , user_id )
VALUES ( $ 1 , $ 2 , $ 3 )
2025-12-29 20:01:55 +03:00
RETURNING id
2026-01-13 18:22:02 +03:00
` , req . WordsCount , req . MaxCards , userID ) . Scan ( & id )
2025-12-29 20:01:55 +03:00
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" ,
2026-01-26 18:45:58 +03:00
"id" : id ,
2025-12-29 20:01:55 +03:00
} )
}
func ( a * App ) updateConfigHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
vars := mux . Vars ( r )
configID := vars [ "id" ]
2026-01-01 18:21:18 +03:00
// 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
}
2025-12-29 20:01:55 +03:00
var req ConfigRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
if req . WordsCount <= 0 {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Количество слов должно быть больше 0" , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
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
2026-01-13 18:22:02 +03:00
SET words_count = $ 1 , max_cards = $ 2
WHERE id = $ 3
` , req . WordsCount , req . MaxCards , configID )
2025-12-29 20:01:55 +03:00
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 )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
vars := mux . Vars ( r )
configID := vars [ "id" ]
2026-01-01 18:21:18 +03:00
result , err := a . DB . Exec ( "DELETE FROM configs WHERE id = $1 AND user_id = $2" , configID , userID )
2025-12-29 20:01:55 +03:00
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 )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
log . Printf ( "getWeeklyStatsHandler called from %s, path: %s, user: %d" , r . RemoteAddr , r . URL . Path , userID )
2025-12-29 20:01:55 +03:00
2026-01-26 18:45:58 +03:00
// Получаем данные текущей недели напрямую из 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
2025-12-29 20:01:55 +03:00
}
2026-02-04 15:04:58 +03:00
// Получаем сегодняшние приросты
todayScores , err := a . getTodayScores ( userID )
if err != nil {
log . Printf ( "Error getting today scores: %v" , err )
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
2025-12-29 20:01:55 +03:00
query := `
SELECT
2026-01-26 18:45:58 +03:00
p . id AS project_id ,
2025-12-29 20:01:55 +03:00
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 ,
2026-01-06 15:09:58 +03:00
wg . priority AS priority
2025-12-29 20:01:55 +03:00
FROM
2025-12-30 18:27:12 +03:00
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
2025-12-29 20:01:55 +03:00
LEFT JOIN
weekly_report_mv wr
2025-12-30 18:27:12 +03:00
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
2025-12-29 20:01:55 +03:00
WHERE
2026-01-01 18:21:18 +03:00
p . deleted = FALSE AND p . user_id = $ 1
2025-12-29 20:01:55 +03:00
ORDER BY
total_score DESC
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID )
2025-12-29 20:01:55 +03:00
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
2026-01-26 18:45:58 +03:00
var projectID int
2025-12-30 18:27:12 +03:00
var minGoalScore sql . NullFloat64
2025-12-29 20:01:55 +03:00
var maxGoalScore sql . NullFloat64
var priority sql . NullInt64
err := rows . Scan (
2026-01-26 18:45:58 +03:00
& projectID ,
2025-12-29 20:01:55 +03:00
& project . ProjectName ,
& project . TotalScore ,
2025-12-30 18:27:12 +03:00
& minGoalScore ,
2025-12-29 20:01:55 +03:00
& maxGoalScore ,
& priority ,
)
if err != nil {
log . Printf ( "Error scanning weekly stats row: %v" , err )
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
2026-01-26 18:45:58 +03:00
// Объединяем данные: если есть данные текущей недели, используем их вместо MV
if currentWeekScore , exists := currentWeekScores [ projectID ] ; exists {
project . TotalScore = currentWeekScore
}
2026-02-04 15:04:58 +03:00
// Добавляем сегодняшний прирост
if todayScore , exists := todayScores [ projectID ] ; exists && todayScore != 0 {
project . TodayChange = & todayScore
}
2025-12-30 18:27:12 +03:00
if minGoalScore . Valid {
project . MinGoalScore = minGoalScore . Float64
} else {
project . MinGoalScore = 0
}
2025-12-29 20:01:55 +03:00
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
2025-12-30 18:27:12 +03:00
minGoalScoreVal := project . MinGoalScore
2025-12-29 20:01:55 +03:00
var maxGoalScoreVal float64
if project . MaxGoalScore != nil {
maxGoalScoreVal = * project . MaxGoalScore
}
// Параметры бонуса в зависимости от priority
2026-02-02 18:56:19 +03:00
var extraBonusLimit float64 = 40
2025-12-29 20:01:55 +03:00
if priorityVal == 1 {
2026-02-02 18:56:19 +03:00
extraBonusLimit = 100
2025-12-29 20:01:55 +03:00
} else if priorityVal == 2 {
2026-02-02 18:56:19 +03:00
extraBonusLimit = 70
2025-12-29 20:01:55 +03:00
}
// Расчет базового прогресса
var baseProgress float64
2025-12-30 18:27:12 +03:00
if minGoalScoreVal > 0 {
baseProgress = ( min ( totalScore , minGoalScoreVal ) / minGoalScoreVal ) * 100.0
2025-12-29 20:01:55 +03:00
}
// Расчет экстра прогресса
var extraProgress float64
2025-12-30 18:27:12 +03:00
denominator := maxGoalScoreVal - minGoalScoreVal
if denominator > 0 && totalScore > minGoalScoreVal {
excess := min ( totalScore , maxGoalScoreVal ) - minGoalScoreVal
2025-12-29 20:01:55 +03:00
extraProgress = ( excess / denominator ) * extraBonusLimit
}
resultScore := baseProgress + extraProgress
project . CalculatedScore = roundToTwoDecimals ( resultScore )
// Группировка для итогового расчета
2025-12-30 18:27:12 +03:00
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
if minGoalScoreVal > 0 {
if _ , exists := groups [ priorityVal ] ; ! exists {
groups [ priorityVal ] = make ( [ ] float64 , 0 )
}
groups [ priorityVal ] = append ( groups [ priorityVal ] , project . CalculatedScore )
2025-12-29 20:01:55 +03:00
}
projects = append ( projects , project )
}
2026-01-21 20:01:24 +03:00
// Вычисляем проценты для каждой группы
groupsProgress := calculateGroupsProgress ( groups )
2026-01-26 18:45:58 +03:00
2026-01-03 16:13:28 +03:00
// Вычисляем общий процент выполнения
2026-01-21 20:01:24 +03:00
total := calculateOverallProgress ( groupsProgress )
2025-12-29 20:01:55 +03:00
response := WeeklyStatsResponse {
2026-01-21 20:01:24 +03:00
Total : total ,
GroupProgress1 : groupsProgress . Group1 ,
GroupProgress2 : groupsProgress . Group2 ,
GroupProgress0 : groupsProgress . Group0 ,
Projects : projects ,
2025-12-29 20:01:55 +03:00
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( response )
}
2026-01-25 16:41:50 +03:00
// 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" )
2025-12-29 20:01:55 +03:00
}
}
2026-01-25 16:41:50 +03:00
// 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" )
2026-01-02 14:47:51 +03:00
2026-01-25 16:45:58 +03:00
// 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 )
2026-01-02 14:47:51 +03:00
2026-01-25 16:41:50 +03:00
// 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 )
2026-01-02 14:47:51 +03:00
}
2026-01-25 16:41:50 +03:00
defer m . Close ( )
2026-01-02 14:47:51 +03:00
2026-01-25 16:41:50 +03:00
// Check if schema_migrations table exists and its state
var schemaExists bool
var currentVersion int64
var isDirty bool
err = a . DB . QueryRow ( `
2026-01-11 21:12:26 +03:00
SELECT EXISTS (
SELECT FROM information_schema . tables
WHERE table_schema = ' public '
2026-01-25 16:41:50 +03:00
AND table_name = ' schema_migrations '
2026-01-11 21:12:26 +03:00
)
2026-01-25 16:41:50 +03:00
` ) . Scan ( & schemaExists )
2026-01-11 21:12:26 +03:00
if err != nil {
2026-01-25 16:41:50 +03:00
log . Printf ( "Warning: Could not check schema_migrations table: %v" , err )
2026-01-11 21:12:26 +03:00
}
2026-01-25 16:41:50 +03:00
// If schema_migrations exists, check its state
if schemaExists {
err = a . DB . QueryRow ( `
SELECT version , dirty FROM schema_migrations LIMIT 1
` ) . Scan ( & currentVersion , & 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
2026-01-11 21:12:26 +03:00
}
}
2026-01-25 16:41:50 +03:00
// 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
2026-01-12 17:02:33 +03:00
WHERE table_schema = ' public '
2026-01-25 16:41:50 +03:00
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" )
2026-01-26 18:45:58 +03:00
2026-01-25 16:41:50 +03:00
// 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 )
}
2026-01-26 18:45:58 +03:00
2026-01-25 16:41:50 +03:00
_ , 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 )
}
2026-01-26 18:45:58 +03:00
2026-01-25 16:41:50 +03:00
log . Println ( "Migration version set to 1 (baseline) for existing database" )
return nil
2026-01-12 17:02:33 +03:00
}
}
2026-01-25 16:41:50 +03:00
// 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
2026-01-12 18:58:52 +03:00
}
2026-01-25 16:41:50 +03:00
return fmt . Errorf ( "failed to apply migrations: %w" , err )
2026-01-12 18:58:52 +03:00
}
2026-01-25 16:41:50 +03:00
log . Println ( "Database migrations applied successfully" )
2026-01-12 18:58:52 +03:00
return nil
}
2026-01-25 16:41:50 +03:00
func ( a * App ) initDB ( ) error {
// This function is kept for backward compatibility but does nothing
// Database schema is now managed by golang-migrate
2026-01-19 13:07:17 +03:00
return nil
}
2026-01-25 16:41:50 +03:00
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()" )
2026-01-25 15:28:37 +03:00
return nil
}
2026-01-25 16:41:50 +03:00
func ( a * App ) initPlayLifeDB ( ) error {
// This function is kept for backward compatibility but does nothing
// Database schema is now managed by golang-migrate
2026-01-25 15:28:37 +03:00
return nil
}
2026-01-25 16:41:50 +03:00
// 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
2026-01-26 18:45:58 +03:00
//
2026-01-25 16:41:50 +03:00
// NOTE: Functions applyMigration012-029 have been removed as they are no longer needed.
// All database schema is now managed by golang-migrate baseline migration.
2026-01-04 19:42:29 +03:00
2026-01-25 16:41:50 +03:00
// 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
2025-12-29 20:01:55 +03:00
return nil
}
// startWeeklyGoalsScheduler запускает планировщик для автоматической фиксации целей на неделю
// каждый понедельник в 6:00 утра в указанном часовом поясе
func ( a * App ) startWeeklyGoalsScheduler ( ) {
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
timezoneStr := getEnv ( "TIMEZONE" , "UTC" )
2025-12-30 18:27:12 +03:00
log . Printf ( "Loading timezone for weekly goals scheduler: '%s'" , timezoneStr )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Загружаем часовой пояс
loc , err := time . LoadLocation ( timezoneStr )
if err != nil {
log . Printf ( "Warning: Invalid timezone '%s': %v. Using UTC instead." , timezoneStr , err )
2025-12-30 18:27:12 +03:00
log . Printf ( "Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'" )
2025-12-29 20:01:55 +03:00
loc = time . UTC
2025-12-30 18:27:12 +03:00
timezoneStr = "UTC"
2025-12-29 20:01:55 +03:00
} else {
2025-12-30 18:27:12 +03:00
log . Printf ( "Weekly goals scheduler timezone set to: %s" , timezoneStr )
2025-12-29 20:01:55 +03:00
}
2025-12-30 18:27:12 +03:00
// Логируем текущее время в указанном часовом поясе для проверки
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 )
2025-12-29 20:01:55 +03:00
// Создаем планировщик с указанным часовым поясом
c := cron . New ( cron . WithLocation ( loc ) )
// Добавляем задачу: каждый понедельник в 6:00 утра
// Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1)
_ , err = c . AddFunc ( "0 6 * * 1" , func ( ) {
2025-12-30 18:27:12 +03:00
now := time . Now ( ) . In ( loc )
2026-01-31 18:43:25 +03:00
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" ) )
2026-01-26 18:45:58 +03:00
// Сначала обновляем 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" )
}
2026-01-31 18:43:25 +03:00
// Обновляем projects_median_mv после обновления weekly_report_mv
_ , err = a . DB . Exec ( "REFRESH MATERIALIZED VIEW projects_median_mv" )
if err != nil {
log . Printf ( "Error refreshing projects_median_mv: %v" , err )
} else {
log . Printf ( "Projects median materialized view refreshed successfully" )
}
2026-01-26 18:45:58 +03:00
// Затем настраиваем цели на новую неделю
2025-12-29 20:01:55 +03:00
if err := a . setupWeeklyGoals ( ) ; err != nil {
log . Printf ( "Error in scheduled weekly goals setup: %v" , err )
}
} )
if err != nil {
2026-01-25 16:41:50 +03:00
log . Printf ( "Warning: Failed to add weekly goals scheduler: %v" , err )
2025-12-29 20:01:55 +03:00
return
}
// Запускаем планировщик
c . Start ( )
2026-01-25 16:41:50 +03:00
log . Println ( "Weekly goals scheduler started" )
2025-12-29 20:01:55 +03:00
}
2026-01-26 18:45:58 +03:00
// 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
}
2026-02-04 15:04:58 +03:00
// getTodayScores получает сумму score всех нод, созданных сегодня для конкретного пользователя
// Возвращает map[project_id]today_score для сегодняшнего дня
func ( a * App ) getTodayScores ( userID int ) ( map [ int ] float64 , error ) {
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 DATE ( n . created_date ) = CURRENT_DATE
GROUP BY n . project_id
`
rows , err := a . DB . Query ( query , userID )
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 ) {
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 DATE ( n . created_date ) = CURRENT_DATE
GROUP BY n . project_id
`
rows , err := a . DB . Query ( query )
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
}
2026-01-26 18:45:58 +03:00
// 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
}
2025-12-29 20:01:55 +03:00
// getWeeklyStatsData получает данные о проектах и их целях (без HTTP обработки)
func ( a * App ) getWeeklyStatsData ( ) ( * WeeklyStatsResponse , error ) {
2026-01-26 18:45:58 +03:00
// Получаем данные текущей недели для всех пользователей
currentWeekScores , err := a . getCurrentWeekScoresAllUsers ( )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-26 18:45:58 +03:00
log . Printf ( "Error getting current week scores: %v" , err )
return nil , fmt . Errorf ( "error getting current week scores: %w" , err )
2025-12-29 20:01:55 +03:00
}
2026-02-04 15:04:58 +03:00
// Получаем сегодняшние приросты для всех пользователей
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 )
}
2025-12-29 20:01:55 +03:00
query := `
SELECT
2026-01-26 18:45:58 +03:00
p . id AS project_id ,
2025-12-29 20:01:55 +03:00
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 ,
2026-01-06 15:09:58 +03:00
wg . priority AS priority
2025-12-29 20:01:55 +03:00
FROM
2025-12-30 18:27:12 +03:00
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
2025-12-29 20:01:55 +03:00
LEFT JOIN
weekly_report_mv wr
2025-12-30 18:27:12 +03:00
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
2025-12-29 20:01:55 +03:00
WHERE
2025-12-30 18:27:12 +03:00
p . deleted = FALSE
2025-12-29 20:01:55 +03:00
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
2026-01-26 18:45:58 +03:00
var projectID int
2025-12-30 18:27:12 +03:00
var minGoalScore sql . NullFloat64
2025-12-29 20:01:55 +03:00
var maxGoalScore sql . NullFloat64
var priority sql . NullInt64
err := rows . Scan (
2026-01-26 18:45:58 +03:00
& projectID ,
2025-12-29 20:01:55 +03:00
& project . ProjectName ,
& project . TotalScore ,
2025-12-30 18:27:12 +03:00
& minGoalScore ,
2025-12-29 20:01:55 +03:00
& 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 )
}
2026-01-26 18:45:58 +03:00
// Объединяем данные: если есть данные текущей недели, используем их вместо MV
if currentWeekScore , exists := currentWeekScores [ projectID ] ; exists {
project . TotalScore = currentWeekScore
}
2026-02-04 15:04:58 +03:00
// Добавляем сегодняшний прирост
if todayScore , exists := todayScores [ projectID ] ; exists && todayScore != 0 {
project . TodayChange = & todayScore
}
2025-12-30 18:27:12 +03:00
if minGoalScore . Valid {
project . MinGoalScore = minGoalScore . Float64
} else {
project . MinGoalScore = 0
}
2025-12-29 20:01:55 +03:00
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
2025-12-30 18:27:12 +03:00
minGoalScoreVal := project . MinGoalScore
2025-12-29 20:01:55 +03:00
var maxGoalScoreVal float64
if project . MaxGoalScore != nil {
maxGoalScoreVal = * project . MaxGoalScore
}
// Параметры бонуса в зависимости от priority
2026-02-02 18:56:19 +03:00
var extraBonusLimit float64 = 40
2025-12-29 20:01:55 +03:00
if priorityVal == 1 {
2026-02-02 18:56:19 +03:00
extraBonusLimit = 100
2025-12-29 20:01:55 +03:00
} else if priorityVal == 2 {
2026-02-02 18:56:19 +03:00
extraBonusLimit = 70
2025-12-29 20:01:55 +03:00
}
// Расчет базового прогресса
var baseProgress float64
2025-12-30 18:27:12 +03:00
if minGoalScoreVal > 0 {
baseProgress = ( min ( totalScore , minGoalScoreVal ) / minGoalScoreVal ) * 100.0
2025-12-29 20:01:55 +03:00
}
// Расчет экстра прогресса
var extraProgress float64
2025-12-30 18:27:12 +03:00
denominator := maxGoalScoreVal - minGoalScoreVal
if denominator > 0 && totalScore > minGoalScoreVal {
excess := min ( totalScore , maxGoalScoreVal ) - minGoalScoreVal
2025-12-29 20:01:55 +03:00
extraProgress = ( excess / denominator ) * extraBonusLimit
}
resultScore := baseProgress + extraProgress
project . CalculatedScore = roundToTwoDecimals ( resultScore )
// Группировка для итогового расчета
2025-12-30 18:27:12 +03:00
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
if minGoalScoreVal > 0 {
if _ , exists := groups [ priorityVal ] ; ! exists {
groups [ priorityVal ] = make ( [ ] float64 , 0 )
}
groups [ priorityVal ] = append ( groups [ priorityVal ] , project . CalculatedScore )
2025-12-29 20:01:55 +03:00
}
projects = append ( projects , project )
}
2026-01-21 20:01:24 +03:00
// Вычисляем проценты для каждой группы
groupsProgress := calculateGroupsProgress ( groups )
2026-01-26 18:45:58 +03:00
2026-01-03 16:13:28 +03:00
// Вычисляем общий процент выполнения
2026-01-21 20:01:24 +03:00
total := calculateOverallProgress ( groupsProgress )
2025-12-29 20:01:55 +03:00
response := WeeklyStatsResponse {
2026-01-21 20:01:24 +03:00
Total : total ,
GroupProgress1 : groupsProgress . Group1 ,
GroupProgress2 : groupsProgress . Group2 ,
GroupProgress0 : groupsProgress . Group0 ,
Projects : projects ,
2025-12-29 20:01:55 +03:00
}
return & response , nil
}
2026-01-02 14:47:51 +03:00
// getWeeklyStatsDataForUser получает данные о проектах для конкретного пользователя
func ( a * App ) getWeeklyStatsDataForUser ( userID int ) ( * WeeklyStatsResponse , error ) {
2026-01-26 18:45:58 +03:00
// Получаем данные текущей недели напрямую из nodes
currentWeekScores , err := a . getCurrentWeekScores ( userID )
2026-01-02 14:47:51 +03:00
if err != nil {
2026-01-26 18:45:58 +03:00
log . Printf ( "Error getting current week scores: %v" , err )
return nil , fmt . Errorf ( "error getting current week scores: %w" , err )
2026-01-02 14:47:51 +03:00
}
2026-02-04 15:04:58 +03:00
// Получаем сегодняшние приросты
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 )
}
2026-01-02 14:47:51 +03:00
query := `
SELECT
2026-01-26 18:45:58 +03:00
p . id AS project_id ,
2026-01-02 14:47:51 +03:00
p . name AS project_name ,
COALESCE ( wr . total_score , 0.0000 ) AS total_score ,
wg . min_goal_score ,
wg . max_goal_score ,
2026-01-06 15:09:58 +03:00
wg . priority AS priority
2026-01-02 14:47:51 +03:00
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
2026-01-26 18:45:58 +03:00
var projectID int
2026-01-02 14:47:51 +03:00
var minGoalScore sql . NullFloat64
var maxGoalScore sql . NullFloat64
var priority sql . NullInt64
err := rows . Scan (
2026-01-26 18:45:58 +03:00
& projectID ,
2026-01-02 14:47:51 +03:00
& project . ProjectName ,
& project . TotalScore ,
& minGoalScore ,
& maxGoalScore ,
& priority ,
)
if err != nil {
return nil , fmt . Errorf ( "error scanning weekly stats row: %w" , err )
}
2026-01-26 18:45:58 +03:00
// Объединяем данные: если есть данные текущей недели, используем их вместо MV
if currentWeekScore , exists := currentWeekScores [ projectID ] ; exists {
project . TotalScore = currentWeekScore
}
2026-02-04 15:04:58 +03:00
// Добавляем сегодняшний прирост
if todayScore , exists := todayScores [ projectID ] ; exists && todayScore != 0 {
project . TodayChange = & todayScore
}
2026-01-02 14:47:51 +03:00
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
}
2026-01-03 16:13:28 +03:00
// Параметры бонуса в зависимости от priority
2026-02-02 18:56:19 +03:00
var extraBonusLimit float64 = 40
2026-01-02 14:47:51 +03:00
if priorityVal == 1 {
2026-02-02 18:56:19 +03:00
extraBonusLimit = 100
2026-01-02 14:47:51 +03:00
} else if priorityVal == 2 {
2026-02-02 18:56:19 +03:00
extraBonusLimit = 70
2026-01-02 14:47:51 +03:00
}
2026-01-03 16:13:28 +03:00
// Расчет базового прогресса
var baseProgress float64
2026-01-02 14:47:51 +03:00
if minGoalScoreVal > 0 {
2026-01-03 16:13:28 +03:00
baseProgress = ( min ( totalScore , minGoalScoreVal ) / minGoalScoreVal ) * 100.0
2026-01-02 14:47:51 +03:00
}
2026-01-03 16:13:28 +03:00
// Расчет экстра прогресса
var extraProgress float64
denominator := maxGoalScoreVal - minGoalScoreVal
if denominator > 0 && totalScore > minGoalScoreVal {
excess := min ( totalScore , maxGoalScoreVal ) - minGoalScoreVal
extraProgress = ( excess / denominator ) * extraBonusLimit
2026-01-02 14:47:51 +03:00
}
2026-01-03 16:13:28 +03:00
resultScore := baseProgress + extraProgress
project . CalculatedScore = roundToTwoDecimals ( resultScore )
projects = append ( projects , project )
// Группировка для итогового расчета
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
if minGoalScoreVal > 0 {
if _ , exists := groups [ priorityVal ] ; ! exists {
groups [ priorityVal ] = make ( [ ] float64 , 0 )
2026-01-02 14:47:51 +03:00
}
2026-01-03 16:13:28 +03:00
groups [ priorityVal ] = append ( groups [ priorityVal ] , project . CalculatedScore )
2026-01-02 14:47:51 +03:00
}
}
2026-01-21 20:01:24 +03:00
// Вычисляем проценты для каждой группы
groupsProgress := calculateGroupsProgress ( groups )
2026-01-26 18:45:58 +03:00
2026-01-03 16:13:28 +03:00
// Вычисляем общий процент выполнения
2026-01-21 20:01:24 +03:00
total := calculateOverallProgress ( groupsProgress )
2026-01-02 14:47:51 +03:00
response := WeeklyStatsResponse {
2026-01-21 20:01:24 +03:00
Total : total ,
GroupProgress1 : groupsProgress . Group1 ,
GroupProgress2 : groupsProgress . Group2 ,
GroupProgress0 : groupsProgress . Group0 ,
Projects : projects ,
2026-01-02 14:47:51 +03:00
}
return & response , nil
}
2025-12-29 20:01:55 +03:00
// formatDailyReport форматирует данные проектов в сообщение для Telegram
// Формат аналогичен JS коду из n8n
func ( a * App ) formatDailyReport ( data * WeeklyStatsResponse ) string {
if data == nil || len ( data . Projects ) == 0 {
return ""
}
// Заголовок сообщения
2026-01-13 16:44:22 +03:00
markdownMessage := "*📈 Отчет:*\n\n"
2025-12-29 20:01:55 +03:00
// Простой вывод списка проектов
for _ , item := range data . Projects {
projectName := item . ProjectName
if projectName == "" {
projectName = "Без названия"
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
actualScore := item . TotalScore
minGoal := item . MinGoalScore
var maxGoal float64
hasMaxGoal := false
if item . MaxGoalScore != nil {
maxGoal = * item . MaxGoalScore
hasMaxGoal = true
}
// Форматирование Score (+/-)
scoreFormatted := ""
if actualScore >= 0 {
scoreFormatted = fmt . Sprintf ( "+%.2f" , actualScore )
} else {
scoreFormatted = fmt . Sprintf ( "%.2f" , actualScore )
}
// Форматирование текста целей
// Проверяем, что minGoal валиден (не NaN, как в JS коде: !isNaN(minGoal))
goalText := ""
if ! math . IsNaN ( minGoal ) {
if hasMaxGoal && ! math . IsNaN ( maxGoal ) {
goalText = fmt . Sprintf ( " (Цель: %.1f– %.1f)" , minGoal , maxGoal )
} else {
goalText = fmt . Sprintf ( " (Цель: мин. %.1f)" , minGoal )
}
}
// Собираем строку: Проект: +Score (Цели)
markdownMessage += fmt . Sprintf ( "*%s*: %s%s\n" , projectName , scoreFormatted , goalText )
}
// Выводим итоговый total из корня JSON
if data . Total != nil {
markdownMessage += "\n---\n"
markdownMessage += fmt . Sprintf ( "*Общее выполнение целей*: %.1f%%" , * data . Total )
}
return markdownMessage
}
2026-01-02 14:47:51 +03:00
// sendDailyReport отправляет персональные ежедневные отчеты всем пользователям
2025-12-29 20:01:55 +03:00
func ( a * App ) sendDailyReport ( ) error {
2026-01-02 14:47:51 +03:00
log . Printf ( "Scheduled task: Sending daily reports" )
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
userIDs , err := a . getAllUsersWithTelegram ( )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-02 14:47:51 +03:00
return fmt . Errorf ( "error getting users: %w" , err )
2025-12-29 20:01:55 +03:00
}
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
if len ( userIDs ) == 0 {
log . Printf ( "No users with Telegram connected, skipping daily report" )
2025-12-29 20:01:55 +03:00
return nil
}
2026-01-02 14:47:51 +03:00
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 )
}
}
2025-12-29 20:01:55 +03:00
return nil
}
// startDailyReportScheduler запускает планировщик для ежедневного отчета
2025-12-30 18:27:12 +03:00
// каждый день в 23:59 в указанном часовом поясе
2025-12-29 20:01:55 +03:00
func ( a * App ) startDailyReportScheduler ( ) {
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
timezoneStr := getEnv ( "TIMEZONE" , "UTC" )
2025-12-30 18:27:12 +03:00
log . Printf ( "Loading timezone for daily report scheduler: '%s'" , timezoneStr )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Загружаем часовой пояс
loc , err := time . LoadLocation ( timezoneStr )
if err != nil {
log . Printf ( "Warning: Invalid timezone '%s': %v. Using UTC instead." , timezoneStr , err )
2025-12-30 18:27:12 +03:00
log . Printf ( "Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'" )
2025-12-29 20:01:55 +03:00
loc = time . UTC
2025-12-30 18:27:12 +03:00
timezoneStr = "UTC"
2025-12-29 20:01:55 +03:00
} else {
log . Printf ( "Daily report scheduler timezone set to: %s" , timezoneStr )
}
2025-12-30 18:27:12 +03:00
// Логируем текущее время в указанном часовом поясе для проверки
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 )
2025-12-29 20:01:55 +03:00
// Создаем планировщик с указанным часовым поясом
c := cron . New ( cron . WithLocation ( loc ) )
2025-12-30 18:27:12 +03:00
// Добавляем задачу: каждый день в 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" ) )
2025-12-29 20:01:55 +03:00
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 ( )
2025-12-30 18:27:12 +03:00
log . Printf ( "Daily report scheduler started: every day at 23:59 %s" , timezoneStr )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Планировщик будет работать в фоновом режиме
}
2026-01-28 20:19:53 +03:00
// 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 )
// Планировщик будет работать в фоновом режиме
}
2026-01-07 15:31:40 +03:00
// readVersion читает версию из файла VERSION
func readVersion ( ) string {
// Пробуем разные пути к файлу VERSION
paths := [ ] string {
2026-01-26 18:45:58 +03:00
"/app/VERSION" , // В Docker контейнере
"../VERSION" , // При запуске из play-life-backend/
"../../VERSION" , // Альтернативный путь
"VERSION" , // Текущая директория
2026-01-07 15:31:40 +03:00
}
for _ , path := range paths {
data , err := os . ReadFile ( path )
if err == nil {
version := strings . TrimSpace ( string ( data ) )
if version != "" {
return version
}
}
}
return "unknown"
}
2025-12-29 20:01:55 +03:00
func main ( ) {
2026-01-07 15:31:40 +03:00
// Читаем версию приложения
version := readVersion ( )
log . Printf ( "========================================" )
log . Printf ( "Play Life Backend v%s" , version )
log . Printf ( "========================================" )
2025-12-29 20:01:55 +03:00
// Загружаем переменные окружения из .env файла (если существует)
// Сначала пробуем загрузить из корня проекта, затем из текущей директории
// Игнорируем ошибку, если файл не найден
godotenv . Load ( "../.env" ) // Пробуем корневой .env
godotenv . Load ( ".env" ) // Пробуем локальный .env
dbHost := getEnv ( "DB_HOST" , "localhost" )
dbPort := getEnv ( "DB_PORT" , "5432" )
dbUser := getEnv ( "DB_USER" , "playeng" )
dbPassword := getEnv ( "DB_PASSWORD" , "playeng" )
dbName := getEnv ( "DB_NAME" , "playeng" )
// Логируем параметры подключения к БД (без пароля)
log . Printf ( "Database connection parameters: host=%s port=%s user=%s dbname=%s" , dbHost , dbPort , dbUser , dbName )
dsn := fmt . Sprintf ( "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable" ,
dbHost , dbPort , dbUser , dbPassword , dbName )
var db * sql . DB
var err error
// Retry connection
for i := 0 ; i < 10 ; i ++ {
db , err = sql . Open ( "postgres" , dsn )
if err == nil {
err = db . Ping ( )
if err == nil {
break
}
}
if i < 9 {
time . Sleep ( 2 * time . Second )
}
}
if err != nil {
log . Fatal ( "Failed to connect to database:" , err )
}
log . Printf ( "Successfully connected to database: %s@%s:%s/%s" , dbUser , dbHost , dbPort , dbName )
defer db . Close ( )
2025-12-31 19:11:28 +03:00
// Telegram бот теперь загружается из БД при необходимости
// Webhook будет настроен автоматически при сохранении bot token через UI
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
// 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." )
}
2026-01-26 18:45:58 +03:00
2025-12-31 19:11:28 +03:00
app := & App {
2026-01-02 14:47:51 +03:00
DB : db ,
lastWebhookTime : make ( map [ int ] time . Time ) ,
telegramBot : nil ,
telegramBotUsername : "" ,
jwtSecret : [ ] byte ( jwtSecret ) ,
2025-12-31 19:11:28 +03:00
}
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
// Инициализация Telegram бота из .env
telegramBotToken := getEnv ( "TELEGRAM_BOT_TOKEN" , "" )
if telegramBotToken != "" {
bot , err := tgbotapi . NewBotAPI ( telegramBotToken )
2026-01-01 18:38:28 +03:00
if err != nil {
2026-01-02 14:47:51 +03:00
log . Printf ( "WARNING: Failed to initialize Telegram bot: %v" , err )
2026-01-01 18:38:28 +03:00
} else {
2026-01-02 14:47:51 +03:00
app . telegramBot = bot
log . Printf ( "Telegram bot initialized successfully" )
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
// Получаем 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 )
}
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
// Настраиваем 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 )
2026-01-01 18:38:28 +03:00
} else {
2026-01-02 14:47:51 +03:00
log . Printf ( "SUCCESS: Telegram webhook configured: %s" , webhookURL )
2026-01-01 18:38:28 +03:00
}
2025-12-29 20:01:55 +03:00
} else {
2026-01-02 14:47:51 +03:00
log . Printf ( "WEBHOOK_BASE_URL not set. Webhook will not be configured." )
2025-12-29 20:01:55 +03:00
}
}
2025-12-31 19:39:01 +03:00
} else {
2026-01-02 14:47:51 +03:00
log . Printf ( "WARNING: TELEGRAM_BOT_TOKEN not set in environment" )
2025-12-29 20:01:55 +03:00
}
2026-01-25 16:41:50 +03:00
// Apply database migrations
if err := app . runMigrations ( ) ; err != nil {
log . Fatal ( "Failed to apply database migrations:" , err )
2026-01-01 18:21:18 +03:00
}
2026-01-25 16:41:50 +03:00
log . Println ( "Database migrations applied successfully" )
2026-01-01 18:21:18 +03:00
2025-12-29 20:01:55 +03:00
// Запускаем планировщик для автоматической фиксации целей на неделю
app . startWeeklyGoalsScheduler ( )
2025-12-30 18:27:12 +03:00
// Запускаем планировщик для ежедневного отчета в 23:59
2025-12-29 20:01:55 +03:00
app . startDailyReportScheduler ( )
2026-01-28 20:19:53 +03:00
// Запускаем планировщик для автовыполнения задач в конце дня в 23:55
app . startEndOfDayTaskScheduler ( )
2025-12-29 20:01:55 +03:00
r := mux . NewRouter ( )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
// 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" )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
// Webhooks - no auth (external services)
2025-12-29 20:01:55 +03:00
r . HandleFunc ( "/webhook/message/post" , app . messagePostHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-02 15:34:01 +03:00
r . HandleFunc ( "/webhook/todoist" , app . todoistWebhookHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-02 14:47:51 +03:00
r . HandleFunc ( "/webhook/telegram" , app . telegramWebhookHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-26 18:45:58 +03:00
2026-02-02 19:16:49 +03:00
// 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
2025-12-29 20:01:55 +03:00
r . HandleFunc ( "/admin" , app . adminHandler ) . Methods ( "GET" )
r . HandleFunc ( "/admin.html" , app . adminHandler ) . Methods ( "GET" )
2026-01-26 18:45:58 +03:00
2026-02-02 19:16:49 +03:00
// Admin API routes (require authentication and admin privileges)
adminAPIRoutes := r . PathPrefix ( "/" ) . Subrouter ( )
adminAPIRoutes . Use ( app . authMiddleware )
adminAPIRoutes . Use ( app . adminMiddleware )
adminAPIRoutes . HandleFunc ( "/message/post" , app . messagePostHandler ) . Methods ( "POST" , "OPTIONS" )
adminAPIRoutes . HandleFunc ( "/weekly_goals/setup" , app . weeklyGoalsSetupHandler ) . Methods ( "POST" , "OPTIONS" )
adminAPIRoutes . HandleFunc ( "/daily-report/trigger" , app . dailyReportTriggerHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-11 21:12:26 +03:00
// 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 )
2026-01-26 18:45:58 +03:00
2026-01-11 21:12:26 +03:00
// Проверяем, что файл существует
if _ , err := os . Stat ( filePath ) ; os . IsNotExist ( err ) {
http . NotFound ( w , r )
return
}
2026-01-26 18:45:58 +03:00
2026-01-11 21:12:26 +03:00
// Отдаём файл
http . ServeFile ( w , r , filePath )
} ) . Methods ( "GET" )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
// Protected routes (require authentication)
protected := r . PathPrefix ( "/" ) . Subrouter ( )
protected . Use ( app . authMiddleware )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
// 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" )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
// Words & dictionaries
protected . HandleFunc ( "/api/words" , app . getWordsHandler ) . Methods ( "GET" , "OPTIONS" )
protected . HandleFunc ( "/api/words" , app . addWordsHandler ) . Methods ( "POST" , "OPTIONS" )
protected . HandleFunc ( "/api/test/words" , app . getTestWordsHandler ) . Methods ( "GET" , "OPTIONS" )
protected . HandleFunc ( "/api/test/progress" , app . updateTestProgressHandler ) . Methods ( "POST" , "OPTIONS" )
protected . HandleFunc ( "/api/dictionaries" , app . getDictionariesHandler ) . Methods ( "GET" , "OPTIONS" )
protected . HandleFunc ( "/api/dictionaries" , app . addDictionaryHandler ) . Methods ( "POST" , "OPTIONS" )
protected . HandleFunc ( "/api/dictionaries/{id}" , app . updateDictionaryHandler ) . Methods ( "PUT" , "OPTIONS" )
protected . HandleFunc ( "/api/dictionaries/{id}" , app . deleteDictionaryHandler ) . Methods ( "DELETE" , "OPTIONS" )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
// 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" )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
// Projects & stats
protected . HandleFunc ( "/api/weekly-stats" , app . getWeeklyStatsHandler ) . Methods ( "GET" , "OPTIONS" )
protected . HandleFunc ( "/playlife-feed" , app . getWeeklyStatsHandler ) . Methods ( "GET" , "OPTIONS" )
2026-02-02 19:16:49 +03:00
// Note: /message/post, /weekly_goals/setup, /daily-report/trigger moved to adminAPIRoutes
2026-01-01 18:21:18 +03:00
protected . HandleFunc ( "/projects" , app . getProjectsHandler ) . Methods ( "GET" , "OPTIONS" )
protected . HandleFunc ( "/project/priority" , app . setProjectPriorityHandler ) . Methods ( "POST" , "OPTIONS" )
protected . HandleFunc ( "/project/move" , app . moveProjectHandler ) . Methods ( "POST" , "OPTIONS" )
protected . HandleFunc ( "/project/delete" , app . deleteProjectHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-02 16:09:16 +03:00
protected . HandleFunc ( "/project/create" , app . createProjectHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-01 18:21:18 +03:00
protected . HandleFunc ( "/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b" , app . getFullStatisticsHandler ) . Methods ( "GET" , "OPTIONS" )
2026-02-03 18:26:21 +03:00
protected . HandleFunc ( "/api/today-entries" , app . getTodayEntriesHandler ) . Methods ( "GET" , "OPTIONS" )
2026-02-03 18:42:44 +03:00
protected . HandleFunc ( "/api/entries/{id}" , app . deleteEntryHandler ) . Methods ( "DELETE" , "OPTIONS" )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
// Integrations
protected . HandleFunc ( "/api/integrations/telegram" , app . getTelegramIntegrationHandler ) . Methods ( "GET" , "OPTIONS" )
protected . HandleFunc ( "/api/integrations/telegram" , app . updateTelegramIntegrationHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-26 18:45:58 +03:00
2026-01-02 15:34:01 +03:00
// 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" )
2026-01-26 18:45:58 +03:00
2026-01-04 19:42:29 +03:00
// Tasks
protected . HandleFunc ( "/api/tasks" , app . getTasksHandler ) . Methods ( "GET" , "OPTIONS" )
protected . HandleFunc ( "/api/tasks" , app . createTaskHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-28 20:19:53 +03:00
// Специфичные роуты должны быть ПЕРЕД общим роутом /api/tasks/{id}
2026-01-04 19:42:29 +03:00
protected . HandleFunc ( "/api/tasks/{id}/complete" , app . completeTaskHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-10 19:17:03 +03:00
protected . HandleFunc ( "/api/tasks/{id}/complete-and-delete" , app . completeAndDeleteTaskHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-06 15:56:52 +03:00
protected . HandleFunc ( "/api/tasks/{id}/postpone" , app . postponeTaskHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-28 20:19:53 +03:00
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" )
2026-01-26 18:45:58 +03:00
2026-01-11 21:12:26 +03:00
// Wishlist
protected . HandleFunc ( "/api/wishlist" , app . getWishlistHandler ) . Methods ( "GET" , "OPTIONS" )
protected . HandleFunc ( "/api/wishlist" , app . createWishlistHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-13 20:55:44 +03:00
protected . HandleFunc ( "/api/wishlist/completed" , app . getWishlistCompletedHandler ) . Methods ( "GET" , "OPTIONS" )
2026-01-11 21:12:26 +03:00
protected . HandleFunc ( "/api/wishlist/metadata" , app . extractLinkMetadataHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-22 20:11:29 +03:00
protected . HandleFunc ( "/api/wishlist/proxy-image" , app . proxyImageHandler ) . Methods ( "GET" , "OPTIONS" )
2026-01-31 18:43:25 +03:00
protected . HandleFunc ( "/api/wishlist/calculate-weeks" , app . calculateWeeksHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-26 18:45:58 +03:00
2026-01-13 22:35:01 +03:00
// 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" )
2026-01-26 18:45:58 +03:00
2026-01-13 22:35:01 +03:00
// Wishlist items (после boards, чтобы {id} не перехватывал "boards")
2026-01-11 21:12:26 +03:00
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}/complete" , app . completeWishlistHandler ) . Methods ( "POST" , "OPTIONS" )
protected . HandleFunc ( "/api/wishlist/{id}/uncomplete" , app . uncompleteWishlistHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-13 20:55:44 +03:00
protected . HandleFunc ( "/api/wishlist/{id}/copy" , app . copyWishlistHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
// Admin operations
protected . HandleFunc ( "/admin/recreate-mv" , app . recreateMaterializedViewHandler ) . Methods ( "POST" , "OPTIONS" )
2025-12-29 20:01:55 +03:00
port := getEnv ( "PORT" , "8080" )
log . Printf ( "Server starting on port %s" , port )
2026-01-01 18:21:18 +03:00
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" )
2025-12-29 20:01:55 +03:00
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 )
2025-12-31 19:39:01 +03:00
log . Printf ( "Setting up Telegram webhook: apiURL=%s, webhookURL=%s" , apiURL , webhookURL )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
payload := map [ string ] string {
"url" : webhookURL ,
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
jsonData , err := json . Marshal ( payload )
if err != nil {
return fmt . Errorf ( "failed to marshal webhook payload: %w" , err )
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Создаем HTTP клиент с таймаутом
client := & http . Client {
Timeout : 10 * time . Second ,
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
resp , err := client . Post ( apiURL , "application/json" , bytes . NewBuffer ( jsonData ) )
if err != nil {
2025-12-31 19:39:01 +03:00
log . Printf ( "ERROR: Failed to send webhook setup request: %v" , err )
2025-12-29 20:01:55 +03:00
return fmt . Errorf ( "failed to send webhook setup request: %w" , err )
}
defer resp . Body . Close ( )
2026-01-26 18:45:58 +03:00
2026-01-01 18:57:30 +03:00
bodyBytes , err := io . ReadAll ( resp . Body )
if err != nil {
return fmt . Errorf ( "failed to read response body: %w" , err )
}
2025-12-31 19:39:01 +03:00
log . Printf ( "Telegram API response: status=%d, body=%s" , resp . StatusCode , string ( bodyBytes ) )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
if resp . StatusCode != http . StatusOK {
return fmt . Errorf ( "telegram API returned status %d: %s" , resp . StatusCode , string ( bodyBytes ) )
}
2026-01-26 18:45:58 +03:00
2026-01-01 18:57:30 +03:00
// Декодируем из уже прочитанных байтов
2025-12-29 20:01:55 +03:00
var result map [ string ] interface { }
2026-01-01 18:57:30 +03:00
if err := json . Unmarshal ( bodyBytes , & result ) ; err != nil {
2025-12-29 20:01:55 +03:00
return fmt . Errorf ( "failed to decode response: %w" , err )
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
if ok , _ := result [ "ok" ] . ( bool ) ; ! ok {
description , _ := result [ "description" ] . ( string )
return fmt . Errorf ( "telegram API returned error: %s" , description )
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
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
}
2026-01-21 20:01:24 +03:00
// calculateGroupsProgress вычисляет проценты выполнения для каждой группы приоритетов
2026-01-03 16:13:28 +03:00
// groups - карта приоритетов к спискам calculatedScore проектов
2026-01-21 20:01:24 +03:00
// Возвращает структуру GroupsProgress с процентами для каждой группы
2026-01-11 15:00:20 +03:00
// Если какая-то группа отсутствует, она считается как 100%
2026-01-21 20:01:24 +03:00
func calculateGroupsProgress ( groups map [ int ] [ ] float64 ) GroupsProgress {
2026-01-11 15:00:20 +03:00
// В с е г о есть 3 группы: приоритет 1, приоритет 2, приоритет 0
// Вычисляем среднее для каждой группы, если она есть
// Если группы нет, считаем её как 100%
2026-01-26 18:45:58 +03:00
2026-01-21 20:01:24 +03:00
result := GroupsProgress { }
2026-01-26 18:45:58 +03:00
2026-01-11 15:00:20 +03:00
// Обрабатываем все 3 возможных приоритета
priorities := [ ] int { 1 , 2 , 0 }
2026-01-26 18:45:58 +03:00
2026-01-11 15:00:20 +03:00
for _ , priorityVal := range priorities {
scores , exists := groups [ priorityVal ]
2026-01-26 18:45:58 +03:00
2026-01-21 20:01:24 +03:00
var avg float64
2026-01-11 15:00:20 +03:00
if ! exists || len ( scores ) == 0 {
// Если группы нет, считаем как 100%
2026-01-21 20:01:24 +03:00
avg = 100.0
2026-01-11 15:00:20 +03:00
} else {
// Вычисляем среднее для группы
2026-02-02 18:56:19 +03:00
// Для всех приоритетов: если calculated_score > 100%, избыточная часть делится на 2
// Функция для корректировки score: если > 100%, то 100 + (score - 100) / 2
adjustScore := func ( score float64 ) float64 {
if score > 100.0 {
return 100.0 + ( score - 100.0 ) / 2.0
}
return score
}
// Для приоритета 1 и 2 - обычное среднее с корректировкой
2026-01-03 16:13:28 +03:00
if priorityVal == 1 || priorityVal == 2 {
sum := 0.0
for _ , score := range scores {
2026-02-02 18:56:19 +03:00
sum += adjustScore ( score )
2026-01-03 16:13:28 +03:00
}
avg = sum / float64 ( len ( scores ) )
} else {
2026-02-02 18:56:19 +03:00
// Для проектов без приоритета (priorityVal == 0) - специальная формула с корректировкой
2026-01-03 16:13:28 +03:00
projectCount := float64 ( len ( scores ) )
multiplier := 100.0 / ( projectCount * 0.8 )
2026-01-26 18:45:58 +03:00
2026-01-03 16:13:28 +03:00
sum := 0.0
for _ , score := range scores {
2026-02-02 18:56:19 +03:00
// Применяем корректировку перед использованием в формуле
adjustedScore := adjustScore ( score )
2026-01-03 16:13:28 +03:00
// score уже в процентах (например, 80.0), переводим в долю (0.8)
2026-02-02 18:56:19 +03:00
scoreAsDecimal := adjustedScore / 100.0
2026-01-03 16:13:28 +03:00
sum += scoreAsDecimal * multiplier
}
2026-01-26 18:45:58 +03:00
2026-01-03 16:13:28 +03:00
avg = math . Min ( 120.0 , sum )
}
2026-01-21 20:01:24 +03:00
}
2026-01-26 18:45:58 +03:00
2026-01-21 20:01:24 +03:00
// Сохраняем результат в соответствующее поле
avgRounded := roundToFourDecimals ( avg )
switch priorityVal {
case 1 :
result . Group1 = & avgRounded
case 2 :
result . Group2 = & avgRounded
case 0 :
result . Group0 = & avgRounded
2026-01-03 16:13:28 +03:00
}
}
2026-01-26 18:45:58 +03:00
2026-01-21 20:01:24 +03:00
return result
}
// calculateOverallProgress вычисляет общий процент выполнения на основе процентов групп
// groupsProgress - структура с процентами для каждой группы приоритетов
// Возвращает указатель на float64 с общим процентом выполнения
// Всегда вычисляет среднее всех трех групп (даже если какая-то группа отсутствует, она считается как 100%)
func calculateOverallProgress ( groupsProgress GroupsProgress ) * float64 {
2026-01-11 15:00:20 +03:00
// Находим среднее между всеми тремя группами
2026-01-21 20:01:24 +03:00
// Если какая-то группа отсутствует (nil), считаем её как 100%
2026-01-26 18:45:58 +03:00
2026-01-21 20:01:24 +03:00
var group1Val , group2Val , group0Val float64
2026-01-26 18:45:58 +03:00
2026-01-21 20:01:24 +03:00
if groupsProgress . Group1 != nil {
group1Val = * groupsProgress . Group1
} else {
group1Val = 100.0
}
2026-01-26 18:45:58 +03:00
2026-01-21 20:01:24 +03:00
if groupsProgress . Group2 != nil {
group2Val = * groupsProgress . Group2
} else {
group2Val = 100.0
}
2026-01-26 18:45:58 +03:00
2026-01-21 20:01:24 +03:00
if groupsProgress . Group0 != nil {
group0Val = * groupsProgress . Group0
} else {
group0Val = 100.0
2026-01-03 16:13:28 +03:00
}
2026-01-26 18:45:58 +03:00
2026-01-21 20:01:24 +03:00
overallProgress := ( group1Val + group2Val + group0Val ) / 3.0 // Всегда делим на 3, так как групп всегда 3
2026-01-11 15:00:20 +03:00
overallProgressRounded := roundToFourDecimals ( overallProgress )
total := & overallProgressRounded
2026-01-26 18:45:58 +03:00
2026-01-03 16:13:28 +03:00
return total
}
2025-12-31 19:11:28 +03:00
// TelegramIntegration представляет запись из таблицы telegram_integrations
type TelegramIntegration struct {
2026-01-02 14:47:51 +03:00
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 {
2026-01-02 15:34:01 +03:00
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" `
2025-12-31 19:11:28 +03:00
}
// getTelegramIntegration получает telegram интеграцию из БД
2026-01-01 18:21:18 +03:00
// getTelegramIntegrationForUser gets telegram integration for specific user
func ( a * App ) getTelegramIntegrationForUser ( userID int ) ( * TelegramIntegration , error ) {
var integration TelegramIntegration
2026-01-02 14:47:51 +03:00
var telegramUserID sql . NullInt64
var chatID , startToken sql . NullString
var createdAt , updatedAt sql . NullTime
2026-01-01 18:21:18 +03:00
err := a . DB . QueryRow ( `
2026-01-02 14:47:51 +03:00
SELECT id , user_id , telegram_user_id , chat_id , start_token , created_at , updated_at
2026-01-01 18:21:18 +03:00
FROM telegram_integrations
WHERE user_id = $ 1
LIMIT 1
2026-01-02 14:47:51 +03:00
` , userID ) . Scan (
& integration . ID ,
& integration . UserID ,
& telegramUserID ,
& chatID ,
& startToken ,
& createdAt ,
& updatedAt ,
)
2026-01-01 18:21:18 +03:00
if err == sql . ErrNoRows {
2026-01-02 14:47:51 +03:00
// Создаем новую запись с start_token
startTokenValue , err := generateWebhookToken ( )
2026-01-01 18:38:28 +03:00
if err != nil {
2026-01-02 14:47:51 +03:00
return nil , fmt . Errorf ( "failed to generate start token: %w" , err )
2026-01-01 18:38:28 +03:00
}
2026-01-26 18:45:58 +03:00
2026-01-01 18:21:18 +03:00
err = a . DB . QueryRow ( `
2026-01-02 14:47:51 +03:00
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 ,
)
2026-01-01 18:21:18 +03:00
if err != nil {
return nil , fmt . Errorf ( "failed to create telegram integration: %w" , err )
}
2026-01-02 14:47:51 +03:00
startToken = sql . NullString { String : startTokenValue , Valid : true }
2026-01-01 18:21:18 +03:00
} else if err != nil {
return nil , fmt . Errorf ( "failed to get telegram integration: %w" , err )
}
2026-01-02 14:47:51 +03:00
// Заполняем указатели
if telegramUserID . Valid {
integration . TelegramUserID = & telegramUserID . Int64
}
2026-01-01 18:21:18 +03:00
if chatID . Valid {
integration . ChatID = & chatID . String
}
2026-01-02 14:47:51 +03:00
if startToken . Valid {
integration . StartToken = & startToken . String
2026-01-01 18:21:18 +03:00
}
2026-01-02 14:47:51 +03:00
if createdAt . Valid {
integration . CreatedAt = & createdAt . Time
}
if updatedAt . Valid {
integration . UpdatedAt = & updatedAt . Time
2026-01-01 18:38:28 +03:00
}
2026-01-01 18:21:18 +03:00
return & integration , nil
}
2026-01-02 14:47:51 +03:00
// sendTelegramMessageToChat - отправляет сообщение в конкретный чат по chat_id
func ( a * App ) sendTelegramMessageToChat ( chatID int64 , text string ) error {
if a . telegramBot == nil {
return fmt . Errorf ( "telegram bot not initialized" )
2025-12-31 19:11:28 +03:00
}
2026-01-02 14:47:51 +03:00
telegramText := regexp . MustCompile ( ` \*\*([^*]+)\*\* ` ) . ReplaceAllString ( text , "*$1*" )
msg := tgbotapi . NewMessage ( chatID , telegramText )
msg . ParseMode = "Markdown"
2025-12-31 19:11:28 +03:00
2026-01-02 14:47:51 +03:00
_ , err := a . telegramBot . Send ( msg )
if err != nil {
// Проверяем, не заблокирован ли бот
2026-01-26 18:45:58 +03:00
if strings . Contains ( err . Error ( ) , "blocked" ) ||
strings . Contains ( err . Error ( ) , "chat not found" ) ||
strings . Contains ( err . Error ( ) , "bot was blocked" ) {
2026-01-02 14:47:51 +03:00
// Пользователь заблокировал бота - очищаем данные
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
}
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
log . Printf ( "Message sent to chat_id=%d" , chatID )
return nil
2025-12-31 19:11:28 +03:00
}
2026-01-02 14:47:51 +03:00
// 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 )
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
if err == sql . ErrNoRows || ! chatID . Valid {
return fmt . Errorf ( "telegram not connected for user %d" , userID )
}
2025-12-31 19:11:28 +03:00
if err != nil {
2026-01-02 14:47:51 +03:00
return err
2025-12-31 19:11:28 +03:00
}
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
chatIDInt , err := strconv . ParseInt ( chatID . String , 10 , 64 )
2025-12-31 19:11:28 +03:00
if err != nil {
2026-01-02 14:47:51 +03:00
return fmt . Errorf ( "invalid chat_id format: %w" , err )
2025-12-31 19:11:28 +03:00
}
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
return a . sendTelegramMessageToChat ( chatIDInt , text )
2025-12-31 19:11:28 +03:00
}
2026-01-02 14:47:51 +03:00
// 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
` )
2025-12-31 19:11:28 +03:00
if err != nil {
2026-01-02 14:47:51 +03:00
return nil , err
2025-12-29 20:01:55 +03:00
}
2026-01-02 14:47:51 +03:00
defer rows . Close ( )
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
var userIDs [ ] int
for rows . Next ( ) {
var userID int
if err := rows . Scan ( & userID ) ; err == nil {
userIDs = append ( userIDs , userID )
}
2025-12-29 20:01:55 +03:00
}
2026-01-02 14:47:51 +03:00
return userIDs , nil
2025-12-29 20:01:55 +03:00
}
// 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 )
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Конвертируем 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
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Конвертируем UTF-16 кодовые единицы в UTF-8 байты
startRunes := utf16 . Decode ( utf16Runes [ : utf16Offset ] )
endRunes := utf16 . Decode ( utf16Runes [ : utf16Offset + utf16Length ] )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
startBytes := len ( string ( startRunes ) )
endBytes := len ( string ( endRunes ) )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
return endBytes - startBytes
}
// processTelegramMessage обрабатывает сообщение из Telegram с использованием entities
// Логика отличается от processMessage: использует entities для определения жирного текста
// и не отправляет сообщение обратно в Telegram
2026-01-01 18:38:28 +03:00
// userID может быть nil, если пользователь не определен
func ( a * App ) processTelegramMessage ( fullText string , entities [ ] TelegramEntity , userID * int ) ( * ProcessedEntry , error ) {
2025-12-29 20:01:55 +03:00
fullText = strings . TrimSpace ( fullText )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Регулярное выражение: project+/-score (без **)
scoreRegex := regexp . MustCompile ( ` ^([а -яА-ЯёЁ\w]+)([+-])(\d+(?:\.\d+)?)$ ` )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Массив для хранения извлеченных элементов {project, score}
scoreNodes := make ( [ ] ProcessedNode , 0 )
workingText := fullText
placeholderIndex := 0
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Находим все элементы, выделенные жирным шрифтом
boldEntities := make ( [ ] TelegramEntity , 0 )
for _ , entity := range entities {
if entity . Type == "bold" {
boldEntities = append ( boldEntities , entity )
}
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Сортируем в ПРЯМОМ порядке (по offset), чтобы гарантировать, что ${0} соответствует первому в тексте
sort . Slice ( boldEntities , func ( i , j int ) bool {
return boldEntities [ i ] . Offset < boldEntities [ j ] . Offset
} )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Массив для хранения данных, которые будут использоваться для замены в обратном порядке
type ReplacementData struct {
Start int
Length int
Placeholder string
}
replacementData := make ( [ ] ReplacementData , 0 )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
for _ , entity := range boldEntities {
// Telegram использует UTF-16 для offset и length, конвертируем в UTF-8 байты
start := utf16OffsetToUTF8 ( fullText , entity . Offset )
length := utf16LengthToUTF8 ( fullText , entity . Offset , entity . Length )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Извлекаем чистый жирный текст
if start + length > len ( fullText ) {
continue // Пропускаем некорректные entities
}
boldText := strings . TrimSpace ( fullText [ start : start + length ] )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Проверяем соответствие формату
match := scoreRegex . FindStringSubmatch ( boldText )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
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
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Добавляем в массив nodes (по порядку)
scoreNodes = append ( scoreNodes , ProcessedNode {
Project : project ,
Score : score ,
} )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Создаем данные для замены
replacementData = append ( replacementData , ReplacementData {
Start : start ,
Length : length ,
Placeholder : fmt . Sprintf ( "${%d}" , placeholderIndex ) ,
} )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
placeholderIndex ++
}
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Теперь выполняем замены в ОБРАТНОМ порядке, чтобы offset не "смещались"
sort . Slice ( replacementData , func ( i , j int ) bool {
return replacementData [ i ] . Start > replacementData [ j ] . Start
} )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
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 : ]
}
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Удаляем пустые строки и лишние пробелы
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" )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Используем текущее время в формате ISO 8601 (UTC)
createdDate := time . Now ( ) . UTC ( ) . Format ( time . RFC3339 )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Вставляем данные в БД только если есть nodes
if len ( scoreNodes ) > 0 {
2026-01-01 18:38:28 +03:00
err := a . insertMessageData ( processedText , createdDate , scoreNodes , userID )
2025-12-29 20:01:55 +03:00
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" )
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Формируем ответ
response := & ProcessedEntry {
Text : processedText ,
CreatedDate : createdDate ,
Nodes : scoreNodes ,
Raw : fullText ,
Markdown : fullText , // Для Telegram markdown не нужен
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Н Е отправляем сообщение обратно в Telegram (в отличие от processMessage)
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
return response , nil
}
// processMessage обрабатывает текст сообщения: парсит ноды, сохраняет в БД и отправляет в Telegram
2026-01-01 18:21:18 +03:00
func ( a * App ) processMessage ( rawText string , userID * int ) ( * ProcessedEntry , error ) {
return a . processMessageInternal ( rawText , true , userID )
2025-12-29 20:01:55 +03:00
}
// processMessageWithoutTelegram обрабатывает текст сообщения: парсит ноды, сохраняет в БД, но Н Е отправляет в Telegram
2026-01-01 18:21:18 +03:00
func ( a * App ) processMessageWithoutTelegram ( rawText string , userID * int ) ( * ProcessedEntry , error ) {
return a . processMessageInternal ( rawText , false , userID )
2025-12-29 20:01:55 +03:00
}
// processMessageInternal - внутренняя функция обработки сообщения
// sendToTelegram определяет, нужно ли отправлять сообщение в Telegram
2026-01-01 18:21:18 +03:00
func ( a * App ) processMessageInternal ( rawText string , sendToTelegram bool , userID * int ) ( * ProcessedEntry , error ) {
2025-12-29 20:01:55 +03:00
rawText = strings . TrimSpace ( rawText )
// Регулярное выражение для поиска **[Project][+| -][Score]**
regex := regexp . MustCompile ( ` \*\*(.+?)([+-])([\d.]+)\*\* ` )
nodes := make ( [ ] ProcessedNode , 0 )
nodeCounter := 0
// Ищем все node и заменяем их в тексте на плейсхолдеры ${0}, ${1} и т.д.
processedText := regex . ReplaceAllStringFunc ( rawText , func ( fullMatch string ) string {
matches := regex . FindStringSubmatch ( fullMatch )
if len ( matches ) != 4 {
return fullMatch
}
projectName := strings . TrimSpace ( matches [ 1 ] )
sign := matches [ 2 ]
scoreString := matches [ 3 ]
score , err := strconv . ParseFloat ( scoreString , 64 )
if err != nil {
log . Printf ( "Error parsing score: %v" , err )
return fullMatch
}
if sign == "-" {
score = - score
}
// Добавляем данные в массив nodes
nodes = append ( nodes , ProcessedNode {
Project : projectName ,
Score : score ,
} )
placeholder := fmt . Sprintf ( "${%d}" , nodeCounter )
nodeCounter ++
return placeholder
} )
// Удаляем пустые строки и лишние пробелы
lines := strings . Split ( processedText , "\n" )
cleanedLines := make ( [ ] string , 0 )
for _ , line := range lines {
trimmed := strings . TrimSpace ( line )
if trimmed != "" {
cleanedLines = append ( cleanedLines , trimmed )
}
}
processedText = strings . Join ( cleanedLines , "\n" )
// Формируем Markdown (Legacy) контент: заменяем ** на *
markdownText := strings . ReplaceAll ( rawText , "**" , "*" )
// Используем текущее время
createdDate := time . Now ( ) . UTC ( ) . Format ( time . RFC3339 )
// Вставляем данные в БД только если есть nodes
if len ( nodes ) > 0 {
2026-01-01 18:21:18 +03:00
err := a . insertMessageData ( processedText , createdDate , nodes , userID )
2025-12-29 20:01:55 +03:00
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 только если указано
2026-01-02 14:47:51 +03:00
if sendToTelegram && userID != nil {
if err := a . sendTelegramMessageToUser ( * userID , rawText ) ; err != nil {
log . Printf ( "Error sending Telegram message: %v" , err )
}
2025-12-29 20:01:55 +03:00
}
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 )
2026-01-01 18:21:18 +03:00
// Get user ID from context (may be nil for webhook)
var userIDPtr * int
if userID , ok := getUserIDFromContext ( r ) ; ok {
userIDPtr = & userID
}
2025-12-29 20:01:55 +03:00
// Парсим входящий запрос - может быть как {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
}
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Если не нашли в body, пробуем напрямую
if rawText == "" {
if text , ok := rawReq [ "text" ] . ( string ) ; ok {
rawText = text
}
}
// Проверка на наличие нужного поля
if rawText == "" {
sendErrorWithCORS ( w , "Missing 'text' field in body" , http . StatusBadRequest )
return
}
// Обрабатываем сообщение
2026-01-01 18:21:18 +03:00
response , err := a . processMessage ( rawText , userIDPtr )
2025-12-29 20:01:55 +03:00
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 )
}
2026-01-01 18:21:18 +03:00
func ( a * App ) insertMessageData ( entryText string , createdDate string , nodes [ ] ProcessedNode , userID * int ) error {
2025-12-29 20:01:55 +03:00
// Начинаем транзакцию
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 {
2026-01-01 18:21:18 +03:00
if userID != nil {
2026-01-01 18:57:30 +03:00
// Используем более универсальный подход: проверяем существование и вставляем/обновляем
var existingID int
err := tx . QueryRow ( `
SELECT id FROM projects
WHERE name = $ 1 AND user_id = $ 2 AND deleted = FALSE
` , projectName , * userID ) . Scan ( & existingID )
2026-01-26 18:45:58 +03:00
2026-01-01 18:57:30 +03:00
if err == sql . ErrNoRows {
// Проект не существует, создаем новый
2026-01-01 18:21:18 +03:00
_ , err = tx . Exec ( `
INSERT INTO projects ( name , deleted , user_id )
VALUES ( $ 1 , FALSE , $ 2 )
` , projectName , * userID )
if err != nil {
2026-01-01 18:57:30 +03:00
// Если ошибка из-за уникальности, пробуем обновить существующий
_ , 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 )
}
2026-01-01 18:21:18 +03:00
}
2026-01-01 18:57:30 +03:00
} else if err != nil {
return fmt . Errorf ( "failed to check project %s: %w" , projectName , err )
2026-01-01 18:21:18 +03:00
}
2026-01-01 18:57:30 +03:00
// Проект уже существует, ничего не делаем
2026-01-01 18:21:18 +03:00
} else {
2026-01-01 18:57:30 +03:00
// Для случая без user_id (legacy)
var existingID int
err := tx . QueryRow ( `
SELECT id FROM projects
WHERE name = $ 1 AND deleted = FALSE
` , projectName ) . Scan ( & existingID )
2026-01-26 18:45:58 +03:00
2026-01-01 18:57:30 +03:00
if err == sql . ErrNoRows {
// Проект не существует, создаем новый
_ , err = tx . Exec ( `
INSERT INTO projects ( name , deleted )
VALUES ( $ 1 , FALSE )
` , projectName )
if err != nil {
return fmt . Errorf ( "failed to insert project %s: %w" , projectName , err )
}
} else if err != nil {
return fmt . Errorf ( "failed to check project %s: %w" , projectName , err )
2026-01-01 18:21:18 +03:00
}
2026-01-01 18:57:30 +03:00
// Проект уже существует, ничего не делаем
2025-12-29 20:01:55 +03:00
}
}
// 2. Вставляем entry
var entryID int
2026-01-01 18:21:18 +03:00
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 )
}
2025-12-29 20:01:55 +03:00
if err != nil {
return fmt . Errorf ( "failed to insert entry: %w" , err )
}
// 3. Вставляем nodes
for _ , node := range nodes {
2026-01-01 18:57:30 +03:00
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 )
}
2026-01-26 18:45:58 +03:00
2026-01-01 18:57:30 +03:00
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 )
}
2026-01-26 18:45:58 +03:00
// Вставляем node с user_id и created_date (денормализация)
2026-01-01 18:57:30 +03:00
if userID != nil {
_ , err = tx . Exec ( `
2026-01-26 18:45:58 +03:00
INSERT INTO nodes ( project_id , entry_id , score , user_id , created_date )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 )
` , projectID , entryID , node . Score , * userID , createdDate )
2026-01-01 18:57:30 +03:00
} else {
_ , err = tx . Exec ( `
2026-01-26 18:45:58 +03:00
INSERT INTO nodes ( project_id , entry_id , score , created_date )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 )
` , projectID , entryID , node . Score , createdDate )
2026-01-01 18:57:30 +03:00
}
2025-12-29 20:01:55 +03:00
if err != nil {
return fmt . Errorf ( "failed to insert node for project %s: %w" , node . Project , err )
}
}
2026-01-26 18:45:58 +03:00
// MV обновляется только по крону в понедельник в 6:00 утра
// Данные текущей недели берутся напрямую из nodes
2025-12-29 20:01:55 +03:00
// Коммитим транзакцию
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 (
2026-02-02 18:38:25 +03:00
-- Считаем медиану на основе данных за последние 4 недели , исключая текущую неделю
2025-12-29 20:01:55 +03:00
SELECT
project_id ,
2026-01-24 14:31:00 +03:00
PERCENTILE_CONT ( 0.5 ) WITHIN GROUP ( ORDER BY normalized_total_score ) AS median_score
2025-12-29 20:01:55 +03:00
FROM (
SELECT
project_id ,
2026-01-24 14:31:00 +03:00
normalized_total_score ,
2025-12-30 18:27:12 +03:00
report_year ,
report_week ,
2025-12-29 20:01:55 +03:00
-- Нумеруем недели от новых к старым
ROW_NUMBER ( ) OVER ( PARTITION BY project_id ORDER BY report_year DESC , report_week DESC ) as rn
FROM weekly_report_mv
2025-12-30 18:27:12 +03:00
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 )
2025-12-29 20:01:55 +03:00
) sub
2026-02-02 18:38:25 +03:00
WHERE rn <= 4 -- Берем историю за последние 4 недели , исключая текущую неделю
2025-12-29 20:01:55 +03:00
GROUP BY project_id
)
INSERT INTO weekly_goals (
project_id ,
goal_year ,
goal_week ,
min_goal_score ,
max_goal_score ,
2026-01-24 14:31:00 +03:00
max_score ,
2026-01-26 18:05:20 +03:00
priority ,
user_id
2025-12-29 20:01:55 +03:00
)
SELECT
p . id ,
ci . c_year ,
ci . c_week ,
2025-12-30 18:27:12 +03:00
-- Если нет данных ( gm . median_score IS NULL ) , используем 0 ( значение по умолчанию )
2025-12-29 20:01:55 +03:00
COALESCE ( gm . median_score , 0 ) AS min_goal_score ,
2025-12-30 18:27:12 +03:00
-- Логика max_score в зависимости от приоритета ( только если есть данные )
2025-12-29 20:01:55 +03:00
CASE
2025-12-30 18:27:12 +03:00
WHEN gm . median_score IS NULL THEN NULL
2026-02-02 18:56:19 +03:00
WHEN p . priority = 1 THEN gm . median_score * 2.0
WHEN p . priority = 2 THEN gm . median_score * 1.7
ELSE gm . median_score * 1.4
2025-12-30 18:27:12 +03:00
END AS max_goal_score ,
2026-01-24 14:31:00 +03:00
-- max_score ( snapshot ) заполняется при INSERT , но Н Е обновляется при конфликте
CASE
WHEN gm . median_score IS NULL THEN NULL
2026-02-02 18:56:19 +03:00
WHEN p . priority = 1 THEN gm . median_score * 2.0
WHEN p . priority = 2 THEN gm . median_score * 1.7
ELSE gm . median_score * 1.4
2026-01-24 14:31:00 +03:00
END AS max_score ,
2026-01-26 18:05:20 +03:00
p . priority ,
p . user_id
2025-12-29 20:01:55 +03:00
FROM projects p
CROSS JOIN current_info ci
LEFT JOIN goal_metrics gm ON p . id = gm . project_id
2025-12-29 21:31:43 +03:00
WHERE p . deleted = FALSE
2025-12-29 20:01:55 +03:00
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 ,
2026-01-26 18:05:20 +03:00
priority = EXCLUDED . priority ,
user_id = EXCLUDED . user_id
2025-12-29 20:01:55 +03:00
`
_ , 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
}
2026-01-02 14:47:51 +03:00
// getWeeklyGoalsForUser получает цели для конкретного пользователя
func ( a * App ) getWeeklyGoalsForUser ( userID int ) ( [ ] WeeklyGoalSetup , error ) {
2025-12-29 20:01:55 +03:00
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
2025-12-29 21:31:43 +03:00
AND p . deleted = FALSE
2026-01-02 14:47:51 +03:00
AND p . user_id = $ 1
2025-12-29 20:01:55 +03:00
ORDER BY
p . name
`
2026-01-02 14:47:51 +03:00
rows , err := a . DB . Query ( selectQuery , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-02 14:47:51 +03:00
return nil , fmt . Errorf ( "error querying weekly goals: %w" , err )
2025-12-29 20:01:55 +03:00
}
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 )
}
2026-01-02 14:47:51 +03:00
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 )
}
2025-12-29 20:01:55 +03:00
}
return nil
}
// formatWeeklyGoalsMessage форматирует список целей в сообщение для Telegram
// Формат аналогичен JS коду из n8n
func ( a * App ) formatWeeklyGoalsMessage ( goals [ ] WeeklyGoalSetup ) string {
if len ( goals ) == 0 {
return ""
}
// Заголовок сообщения: "Цели на неделю"
2026-01-13 16:44:22 +03:00
markdownMessage := "*🎯 Цели:*\n\n"
2025-12-29 20:01:55 +03:00
// Обработка каждого проекта
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
2025-12-29 21:31:43 +03:00
AND p . deleted = FALSE
2025-12-29 20:01:55 +03:00
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
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
err := rows . Scan (
& goal . ProjectName ,
& goal . MinGoalScore ,
& maxGoalScore ,
)
if err != nil {
log . Printf ( "Error scanning weekly goal row: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error scanning data: %v" , err ) , http . StatusInternalServerError )
return
}
if maxGoalScore . Valid {
goal . MaxGoalScore = maxGoalScore . Float64
} else {
goal . MaxGoalScore = 0.0
}
goals = append ( goals , goal )
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( goals )
}
// dailyReportTriggerHandler обрабатывает запрос на отправку ежедневного отчёта
func ( a * App ) dailyReportTriggerHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
log . Printf ( "Manual trigger: Sending daily report" )
err := a . sendDailyReport ( )
if err != nil {
log . Printf ( "Error in manual daily report trigger: %v" , err )
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] string {
"message" : "Daily report sent successfully" ,
} )
}
func ( a * App ) adminHandler ( w http . ResponseWriter , r * http . Request ) {
// Пробуем найти файл admin.html в разных местах
var adminPath string
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// 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"
}
}
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
http . ServeFile ( w , r , adminPath )
}
2025-12-29 20:58:34 +03:00
// 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 ,
2026-01-24 14:31:00 +03:00
COALESCE ( agg . total_score , 0.0000 ) AS total_score ,
CASE
WHEN wg . max_score IS NULL THEN COALESCE ( agg . total_score , 0.0000 )
ELSE LEAST ( COALESCE ( agg . total_score , 0.0000 ) , wg . max_score )
END AS normalized_total_score
2025-12-29 20:58:34 +03:00
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
2026-01-24 14:31:00 +03:00
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
2025-12-29 21:31:43 +03:00
WHERE
p . deleted = FALSE
2025-12-29 20:58:34 +03:00
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" ,
} )
}
2025-12-29 20:01:55 +03:00
func ( a * App ) getProjectsHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
query := `
SELECT
id AS project_id ,
name AS project_name ,
priority
FROM
projects
2025-12-29 21:31:43 +03:00
WHERE
2026-01-01 18:21:18 +03:00
deleted = FALSE AND user_id = $ 1
2025-12-29 20:01:55 +03:00
ORDER BY
priority ASC NULLS LAST ,
project_name
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "Error querying projects: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error querying projects: %v" , err ) , http . StatusInternalServerError )
return
}
defer rows . Close ( )
projects := make ( [ ] Project , 0 )
for rows . Next ( ) {
var project Project
var priority sql . NullInt64
err := rows . Scan (
& project . ProjectID ,
& project . ProjectName ,
& priority ,
)
if err != nil {
log . Printf ( "Error scanning project row: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error scanning data: %v" , err ) , http . StatusInternalServerError )
return
}
if priority . Valid {
priorityVal := int ( priority . Int64 )
project . Priority = & priorityVal
}
projects = append ( projects , project )
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( projects )
}
func ( a * App ) setProjectPriorityHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
_ = userID // Will be used in SQL queries
2025-12-29 20:01:55 +03:00
// Читаем тело запроса один раз
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
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Сначала пробуем декодировать как прямой массив
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
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Извлекаем 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 )
}
}
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Если не получилось как массив (ошибка декодирования), пробуем как объект с 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
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Извлекаем 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
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
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
2026-01-01 18:21:18 +03:00
WHERE id = $ 1 AND user_id = $ 2
` , project . ID , userID )
2025-12-29 20:01:55 +03:00
} else {
_ , err = tx . Exec ( `
UPDATE projects
SET priority = $ 1
2026-01-01 18:21:18 +03:00
WHERE id = $ 2 AND user_id = $ 3
` , * project . Priority , project . ID , userID )
2025-12-29 20:01:55 +03:00
}
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 ) ,
} )
}
2025-12-29 21:31:43 +03:00
type ProjectMoveRequest struct {
ID int ` json:"id" `
NewName string ` json:"new_name" `
}
type ProjectDeleteRequest struct {
ID int ` json:"id" `
}
2026-01-02 16:09:16 +03:00
type ProjectCreateRequest struct {
Name string ` json:"name" `
}
2025-12-29 21:31:43 +03:00
func ( a * App ) moveProjectHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
_ = userID // Will be used in SQL queries
2025-12-29 21:31:43 +03:00
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 {
2025-12-29 21:38:43 +03:00
// Проект не найден - просто переименовываем текущий проект
_ , err = tx . Exec ( `
UPDATE projects
SET name = $ 1
WHERE id = $ 2
` , req . NewName , req . ID )
2025-12-29 21:31:43 +03:00
if err != nil {
2025-12-29 21:38:43 +03:00
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 )
2025-12-29 21:31:43 +03:00
return
}
2025-12-29 21:38:43 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-26 18:45:58 +03:00
"message" : "Project renamed successfully" ,
2025-12-29 21:38:43 +03:00
"project_id" : req . ID ,
} )
return
2025-12-29 21:31:43 +03:00
} 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
}
2025-12-29 21:38:43 +03:00
// Проект найден - переносим данные в существующий проект
finalProjectID := targetProjectID
2025-12-29 21:31:43 +03:00
// Обновляем все 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
}
// Теперь обновляем оставшиеся записи (те, которые не конфликтуют)
2026-01-26 18:05:20 +03:00
// Обновляем project_id и user_id из целевого проекта
2025-12-29 21:31:43 +03:00
_ , err = tx . Exec ( `
2026-01-26 18:05:20 +03:00
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
2025-12-29 21:31:43 +03:00
` , 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
}
2026-01-26 18:45:58 +03:00
// MV обновляется только по крону в понедельник в 6:00 утра
// Данные текущей недели берутся напрямую из nodes
2025-12-29 21:31:43 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-26 18:45:58 +03:00
"message" : "Project moved successfully" ,
2025-12-29 21:31:43 +03:00
"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 )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 21:31:43 +03:00
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
}
2026-01-01 18:21:18 +03:00
// 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
}
2025-12-29 21:31:43 +03:00
// Начинаем транзакцию
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
}
2026-01-26 18:45:58 +03:00
// MV обновляется только по крону в понедельник в 6:00 утра
// Данные текущей недели берутся напрямую из nodes
2025-12-29 21:31:43 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"message" : "Project deleted successfully" ,
} )
}
2026-01-02 16:09:16 +03:00
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
}
// Создаем новый проект
var projectID int
err = a . DB . QueryRow ( `
INSERT INTO projects ( name , deleted , user_id )
VALUES ( $ 1 , FALSE , $ 2 )
RETURNING id
` , req . Name , userID ) . Scan ( & projectID )
if err != nil {
log . Printf ( "Error creating project: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error creating project: %v" , err ) , http . StatusInternalServerError )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-26 18:45:58 +03:00
"message" : "Project created successfully" ,
"project_id" : projectID ,
2026-01-02 16:09:16 +03:00
"project_name" : req . Name ,
} )
}
2025-12-29 20:01:55 +03:00
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 ( ) )
2026-01-01 18:50:55 +03:00
log . Printf ( "Path: %s" , r . URL . Path )
2025-12-29 20:01:55 +03:00
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 )
2026-01-02 15:34:01 +03:00
// Проверка 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" )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
2026-01-02 15:34:01 +03:00
"error" : "Missing user_id in event_data" ,
"message" : "Cannot identify user" ,
2026-01-01 18:50:55 +03:00
} )
2026-01-01 18:38:28 +03:00
return
}
2026-01-02 15:34:01 +03:00
log . Printf ( "Todoist webhook: todoist_user_id=%d" , todoistUserID )
// Находим пользователя Play Life по todoist_user_id
2026-01-01 18:38:28 +03:00
var userID int
2026-01-02 15:34:01 +03:00
err = a . DB . QueryRow ( `
2026-01-02 14:47:51 +03:00
SELECT user_id FROM todoist_integrations
2026-01-02 15:34:01 +03:00
WHERE todoist_user_id = $ 1
` , todoistUserID ) . Scan ( & userID )
2026-01-01 18:38:28 +03:00
if err == sql . ErrNoRows {
2026-01-02 15:34:01 +03:00
// Пользователь не подключил Play Life — игнорируем
log . Printf ( "Todoist webhook: no user found for todoist_user_id=%d (ignoring)" , todoistUserID )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-02 15:34:01 +03:00
"ok" : true ,
"message" : "User not found (not connected)" ,
2026-01-01 18:50:55 +03:00
} )
2026-01-01 18:38:28 +03:00
return
2026-01-02 15:34:01 +03:00
}
if err != nil {
log . Printf ( "Error finding user by todoist_user_id: %v" , err )
2026-01-01 18:50:55 +03:00
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" ,
} )
2026-01-01 18:38:28 +03:00
return
}
2026-01-02 15:34:01 +03:00
log . Printf ( "Todoist webhook: todoist_user_id=%d -> user_id=%d" , todoistUserID , userID )
2026-01-01 18:38:28 +03:00
2025-12-29 20:01:55 +03:00
// Извлекаем content (title) и description из event_data
log . Printf ( "Extracting content and description from event_data..." )
var title , description string
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
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" ] )
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
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 ) )
2026-01-01 18:50:55 +03:00
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" ,
} )
2025-12-29 20:01:55 +03:00
return
}
2026-01-26 18:45:58 +03:00
log . Printf ( "Processing Todoist task: title='%s' (len=%d), description='%s' (len=%d), combined='%s' (len=%d)" ,
2025-12-29 20:01:55 +03:00
title , len ( title ) , description , len ( description ) , combinedText , len ( combinedText ) )
// Обрабатываем сообщение через существующую логику (без отправки в Telegram)
2026-01-01 18:38:28 +03:00
userIDPtr := & userID
log . Printf ( "Calling processMessageWithoutTelegram with combined text, user_id=%d..." , userID )
response , err := a . processMessageWithoutTelegram ( combinedText , userIDPtr )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "ERROR processing Todoist message: %v" , err )
2026-01-01 18:50:55 +03:00
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" ,
} )
2025-12-29 20:01:55 +03:00
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" )
2026-01-01 18:57:30 +03:00
w . WriteHeader ( http . StatusOK )
2025-12-29 20:01:55 +03:00
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-01 18:57:30 +03:00
"ok" : true ,
2025-12-29 20:01:55 +03:00
"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 )
}
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
// Отправляем сообщение в Telegram после успешной обработки
log . Printf ( "Preparing to send message to Telegram..." )
log . Printf ( "Combined text to send: '%s'" , combinedText )
2026-01-02 14:47:51 +03:00
if err := a . sendTelegramMessageToUser ( userID , combinedText ) ; err != nil {
log . Printf ( "Error sending Telegram message: %v" , err )
} else {
log . Printf ( "sendTelegramMessage call completed" )
}
2025-12-29 20:01:55 +03:00
} else {
log . Printf ( "No nodes found, skipping Telegram message" )
}
log . Printf ( "=== Todoist Webhook Request Completed Successfully ===" )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-01 18:50:55 +03:00
w . WriteHeader ( http . StatusOK )
2025-12-29 20:01:55 +03:00
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-01 18:50:55 +03:00
"ok" : true ,
2025-12-29 20:01:55 +03:00
"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 )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-02 14:47:51 +03:00
"ok" : false ,
"error" : "Invalid request body" ,
2026-01-01 18:50:55 +03:00
} )
2025-12-29 20:01:55 +03:00
return
}
2026-01-02 14:47:51 +03:00
// Определяем сообщение
2025-12-31 19:39:01 +03:00
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" )
2026-01-02 14:47:51 +03:00
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] bool { "ok" : true } )
2025-12-31 19:39:01 +03:00
return
}
2026-01-02 14:47:51 +03:00
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
}
2025-12-31 19:39:01 +03:00
2026-01-02 14:47:51 +03:00
telegramUserID := message . From . ID
chatID := message . Chat . ID
chatIDStr := strconv . FormatInt ( chatID , 10 )
2026-01-26 18:45:58 +03:00
log . Printf ( "Telegram webhook: telegram_user_id=%d, chat_id=%d, text=%s" ,
2026-01-02 14:47:51 +03:00
telegramUserID , chatID , message . Text )
// Обработка команды /start с токеном
if strings . HasPrefix ( message . Text , "/start" ) {
parts := strings . Fields ( message . Text )
if len ( parts ) > 1 {
startToken := parts [ 1 ]
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
var userID int
err := a . DB . QueryRow ( `
SELECT user_id FROM telegram_integrations
WHERE start_token = $ 1
` , startToken ) . Scan ( & userID )
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
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 )
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
if err != nil {
log . Printf ( "Error updating telegram integration: %v" , err )
} else {
log . Printf ( "Telegram connected for user_id=%d" , userID )
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
// Приветственное сообщение
welcomeMsg := "✅ Telegram успешно подключен к Play Life!\n\nТ е пе р ь вы будете получать уведомления и отчеты."
if err := a . sendTelegramMessageToChat ( chatID , welcomeMsg ) ; err != nil {
log . Printf ( "Error sending welcome message: %v" , err )
}
}
2025-12-31 19:39:01 +03:00
} else {
2026-01-02 14:47:51 +03:00
log . Printf ( "Invalid start_token: %s" , startToken )
a . sendTelegramMessageToChat ( chatID , "❌ Неверный токен. Попробуйте получить новую ссылку в приложении." )
2025-12-31 19:11:28 +03:00
}
2026-01-02 14:47:51 +03:00
} else {
// /start без токена
a . sendTelegramMessageToChat ( chatID , "Привет! Для подключения используйте ссылку из приложения Play Life." )
2025-12-31 19:11:28 +03:00
}
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] bool { "ok" : true } )
return
2025-12-31 19:11:28 +03:00
}
2026-01-02 14:47:51 +03:00
// Обычное сообщение - ищем пользователя по telegram_user_id
var userID int
err := a . DB . QueryRow ( `
SELECT user_id FROM telegram_integrations
WHERE telegram_user_id = $ 1
` , telegramUserID ) . Scan ( & userID )
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
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 )
2025-12-31 19:11:28 +03:00
2026-01-02 14:47:51 +03:00
// Обрабатываем сообщение
2025-12-31 19:39:01 +03:00
if message . Text == "" {
2025-12-29 20:01:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-02 14:47:51 +03:00
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] bool { "ok" : true } )
2025-12-29 20:01:55 +03:00
return
}
2025-12-31 19:39:01 +03:00
entities := message . Entities
2025-12-29 20:01:55 +03:00
if entities == nil {
entities = [ ] TelegramEntity { }
}
2026-01-02 14:47:51 +03:00
userIDPtr := & userID
response , err := a . processTelegramMessage ( message . Text , entities , userIDPtr )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-02 14:47:51 +03:00
log . Printf ( "Error processing message: %v" , err )
2025-12-29 20:01:55 +03:00
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-02 14:47:51 +03:00
"ok" : true ,
"result" : response ,
2025-12-29 20:01:55 +03:00
} )
}
func ( a * App ) getFullStatisticsHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2026-01-26 18:45:58 +03:00
// Получаем данные текущей недели
currentWeekScores , err := a . getCurrentWeekScores ( userID )
if err != nil {
log . Printf ( "Error getting current week scores: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error getting current week scores: %v" , err ) , http . StatusInternalServerError )
return
}
// Получаем ISO год и неделю для текущей даты
now := time . Now ( )
_ , currentWeekInt := now . ISOWeek ( )
currentYearInt := now . Year ( )
2025-12-29 20:01:55 +03:00
query := `
SELECT
p . name AS project_name ,
-- Определяем год и неделю , беря значение из той таблицы , где оно не NULL
COALESCE ( wr . report_year , wg . goal_year ) AS report_year ,
COALESCE ( wr . report_week , wg . goal_week ) AS report_week ,
-- Фактический score : COALESCE ( NULL , 0.0000 )
COALESCE ( wr . total_score , 0.0000 ) AS total_score ,
-- Минимальная цель : COALESCE ( NULL , 0.0000 )
COALESCE ( wg . min_goal_score , 0.0000 ) AS min_goal_score ,
-- Максимальная цель : COALESCE ( NULL , 0.0000 )
2026-01-26 18:45:58 +03:00
COALESCE ( wg . max_goal_score , 0.0000 ) AS max_goal_score ,
p . id AS project_id
2025-12-29 20:01:55 +03:00
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
2025-12-29 21:31:43 +03:00
ON p . id = COALESCE ( wr . project_id , wg . project_id )
WHERE
2026-01-01 18:21:18 +03:00
p . deleted = FALSE AND p . user_id = $ 1
AND COALESCE ( wr . report_year , wg . goal_year ) IS NOT NULL
2025-12-29 20:01:55 +03:00
ORDER BY
report_year DESC ,
report_week DESC ,
project_name
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID )
2025-12-29 20:01:55 +03:00
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 )
2026-01-26 18:45:58 +03:00
2025-12-29 20:01:55 +03:00
for rows . Next ( ) {
var item FullStatisticsItem
2026-01-26 18:45:58 +03:00
var projectID int
2025-12-29 20:01:55 +03:00
err := rows . Scan (
& item . ProjectName ,
& item . ReportYear ,
& item . ReportWeek ,
& item . TotalScore ,
& item . MinGoalScore ,
& item . MaxGoalScore ,
2026-01-26 18:45:58 +03:00
& projectID ,
2025-12-29 20:01:55 +03:00
)
if err != nil {
log . Printf ( "Error scanning full statistics row: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error scanning data: %v" , err ) , http . StatusInternalServerError )
return
}
2026-01-26 18:45:58 +03:00
// Если это текущая неделя, заменяем данные из MV на данные из nodes
if item . ReportYear == currentYearInt && item . ReportWeek == currentWeekInt {
if score , exists := currentWeekScores [ projectID ] ; exists {
item . TotalScore = score
}
}
2025-12-29 20:01:55 +03:00
statistics = append ( statistics , item )
}
2026-01-26 18:45:58 +03:00
// Добавляем проекты текущей недели, которых нет в MV (новые проекты без исторических данных)
// Получаем goals для текущей недели
currentWeekGoalsQuery := `
SELECT
p . id AS project_id ,
p . name AS project_name ,
COALESCE ( wg . min_goal_score , 0.0000 ) AS min_goal_score ,
COALESCE ( wg . max_goal_score , 0.0000 ) AS max_goal_score
FROM projects p
LEFT JOIN weekly_goals wg ON wg . project_id = p . id
AND wg . goal_year = EXTRACT ( ISOYEAR FROM CURRENT_DATE ) : : INTEGER
AND wg . goal_week = EXTRACT ( WEEK FROM CURRENT_DATE ) : : INTEGER
WHERE p . deleted = FALSE AND p . user_id = $ 1
AND NOT EXISTS (
SELECT 1 FROM weekly_report_mv wr
WHERE wr . project_id = p . id
AND wr . report_year = EXTRACT ( ISOYEAR FROM CURRENT_DATE ) : : INTEGER
AND wr . report_week = EXTRACT ( WEEK FROM CURRENT_DATE ) : : INTEGER
)
`
goalsRows , err := a . DB . Query ( currentWeekGoalsQuery , userID )
if err == nil {
defer goalsRows . Close ( )
existingProjects := make ( map [ int ] bool )
for _ , stat := range statistics {
if stat . ReportYear == currentYearInt && stat . ReportWeek == currentWeekInt {
// Найдем project_id по имени проекта (не идеально, но работает)
var pid int
if err := a . DB . QueryRow ( "SELECT id FROM projects WHERE name = $1 AND user_id = $2" , stat . ProjectName , userID ) . Scan ( & pid ) ; err == nil {
existingProjects [ pid ] = true
}
}
}
for goalsRows . Next ( ) {
var projectID int
var projectName string
var minGoalScore , maxGoalScore float64
if err := goalsRows . Scan ( & projectID , & projectName , & minGoalScore , & maxGoalScore ) ; err == nil {
// Добавляем только если проекта еще нет в статистике
if ! existingProjects [ projectID ] {
totalScore := 0.0
if score , exists := currentWeekScores [ projectID ] ; exists {
totalScore = score
}
_ , weekISO := time . Now ( ) . ISOWeek ( )
item := FullStatisticsItem {
ProjectName : projectName ,
ReportYear : time . Now ( ) . Year ( ) ,
ReportWeek : weekISO ,
TotalScore : totalScore ,
MinGoalScore : minGoalScore ,
MaxGoalScore : maxGoalScore ,
}
statistics = append ( statistics , item )
}
}
}
}
2025-12-29 20:01:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( statistics )
}
2026-02-03 18:26:21 +03:00
// 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 {
targetDate = time . Now ( )
}
// Запрос для получения entries с nodes за указанный день
2026-02-04 14:20:29 +03:00
// Если указан проект, показываем все записи, которые содержат хотя бы одну ноду этого проекта,
// но возвращаем все ноды этих записей, а не только ноды выбранного проекта
2026-02-03 18:26:21 +03:00
query := `
2026-02-04 14:20:29 +03:00
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 (
-- Получаем все ноды для найденных записей ( или всех записей , если проект не указан )
2026-02-03 18:26:21 +03:00
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
2026-02-04 14:20:29 +03:00
AND ( $ 2 : : text IS NULL OR e . id IN ( SELECT entry_id FROM filtered_entries ) )
2026-02-03 18:26:21 +03:00
)
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
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( entries )
}
2026-02-03 18:42:44 +03:00
// 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" ,
} )
}
2026-01-02 14:47:51 +03:00
// getTelegramIntegrationHandler возвращает текущую telegram интеграцию с deep link
2025-12-31 19:11:28 +03:00
func ( a * App ) getTelegramIntegrationHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
integration , err := a . getTelegramIntegrationForUser ( userID )
2025-12-31 19:11:28 +03:00
if err != nil {
sendErrorWithCORS ( w , fmt . Sprintf ( "Failed to get telegram integration: %v" , err ) , http . StatusInternalServerError )
return
}
2026-01-02 14:47:51 +03:00
// Генерируем 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
2025-12-31 19:11:28 +03:00
2026-01-02 14:47:51 +03:00
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 ,
} )
2025-12-31 19:11:28 +03:00
}
2026-01-02 14:47:51 +03:00
// updateTelegramIntegrationHandler больше не используется (bot_token теперь в .env)
// Оставлен для совместимости, возвращает ошибку
2025-12-31 19:11:28 +03:00
func ( a * App ) updateTelegramIntegrationHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-26 18:45:58 +03:00
2026-01-02 14:47:51 +03:00
sendErrorWithCORS ( w , "Bot token is now configured via TELEGRAM_BOT_TOKEN environment variable" , http . StatusBadRequest )
2025-12-31 19:11:28 +03:00
}
2026-01-02 15:35:37 +03:00
// OAuthStateClaims структура для OAuth state JWT
type OAuthStateClaims struct {
UserID int ` json:"user_id" `
Type string ` json:"type" `
jwt . RegisteredClaims
}
2026-01-02 15:34:01 +03:00
// generateOAuthState генерирует JWT state для OAuth
2026-01-02 15:42:59 +03:00
func generateOAuthState ( userID int , jwtSecret [ ] byte ) ( string , error ) {
2026-01-02 15:35:37 +03:00
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 )
2026-01-02 15:42:59 +03:00
return token . SignedString ( jwtSecret )
2026-01-02 15:34:01 +03:00
}
// validateOAuthState проверяет и извлекает user_id из JWT state
2026-01-02 15:42:59 +03:00
func validateOAuthState ( stateString string , jwtSecret [ ] byte ) ( int , error ) {
2026-01-02 15:35:37 +03:00
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" ] )
}
2026-01-02 15:42:59 +03:00
return jwtSecret , nil
2026-01-02 15:34:01 +03:00
} )
if err != nil {
return 0 , err
}
2026-01-02 15:35:37 +03:00
claims , ok := token . Claims . ( * OAuthStateClaims )
2026-01-02 15:34:01 +03:00
if ! ok || ! token . Valid {
return 0 , fmt . Errorf ( "invalid token" )
}
2026-01-02 15:35:37 +03:00
if claims . Type != "todoist_oauth" {
2026-01-02 15:34:01 +03:00
return 0 , fmt . Errorf ( "wrong token type" )
}
2026-01-02 15:35:37 +03:00
return claims . UserID , nil
2026-01-02 15:34:01 +03:00
}
// 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
}
2026-01-02 15:45:54 +03:00
// Формируем правильный запрос к Sync API
data := url . Values { }
data . Set ( "sync_token" , "*" )
data . Set ( "resource_types" , ` ["user"] ` )
2026-01-26 18:45:58 +03:00
2026-01-02 15:45:54 +03:00
req , err := http . NewRequest ( "POST" , "https://api.todoist.com/sync/v9/sync" , strings . NewReader ( data . Encode ( ) ) )
2026-01-02 15:34:01 +03:00
if err != nil {
2026-01-02 15:45:54 +03:00
log . Printf ( "Todoist API: failed to create request: %v" , err )
2026-01-02 15:34:01 +03:00
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" )
2026-01-02 15:45:54 +03:00
log . Printf ( "Todoist API: requesting user info from sync/v9/sync" )
2026-01-02 15:34:01 +03:00
client := & http . Client { Timeout : 10 * time . Second }
resp , err := client . Do ( req )
if err != nil {
2026-01-02 15:45:54 +03:00
log . Printf ( "Todoist API: request failed: %v" , err )
2026-01-02 15:34:01 +03:00
return userInfo , fmt . Errorf ( "failed to get user info: %w" , err )
}
defer resp . Body . Close ( )
2026-01-02 15:45:54 +03:00
bodyBytes , _ := io . ReadAll ( resp . Body )
log . Printf ( "Todoist API: response status=%d, body=%s" , resp . StatusCode , string ( bodyBytes ) )
2026-01-02 15:34:01 +03:00
if resp . StatusCode != http . StatusOK {
2026-01-02 15:45:54 +03:00
return userInfo , fmt . Errorf ( "get user info failed (status %d): %s" , resp . StatusCode , string ( bodyBytes ) )
2026-01-02 15:34:01 +03:00
}
2026-01-02 15:45:54 +03:00
// Парсим ответ - в 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 ) )
2026-01-02 15:34:01 +03:00
return userInfo , fmt . Errorf ( "failed to decode user info: %w" , err )
}
2026-01-02 15:45:54 +03:00
log . Printf ( "Todoist API: parsed response keys: %v" , getMapKeys ( result ) )
2026-01-02 15:46:20 +03:00
// Функция для извлечения 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
}
2026-01-02 15:45:54 +03:00
// Проверяем разные варианты структуры ответа
if userObj , ok := result [ "user" ] . ( map [ string ] interface { } ) ; ok {
// Один объект user
2026-01-02 15:46:20 +03:00
userInfo . ID = extractID ( userObj [ "id" ] )
2026-01-02 15:45:54 +03:00
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 {
2026-01-02 15:46:20 +03:00
userInfo . ID = extractID ( userObj [ "id" ] )
2026-01-02 15:45:54 +03:00
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 )
2026-01-02 15:34:01 +03:00
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"
2026-01-02 15:42:59 +03:00
state , err := generateOAuthState ( userID , a . jwtSecret )
2026-01-02 15:34:01 +03:00
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 ) ,
)
2026-01-02 15:40:06 +03:00
log . Printf ( "Todoist OAuth: returning auth URL for user_id=%d" , userID )
2026-01-26 18:45:58 +03:00
2026-01-02 15:40:06 +03:00
// Возвращаем JSON с URL для редиректа (frontend сделает редирект)
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"auth_url" : authURL ,
} )
2026-01-02 15:34:01 +03:00
}
// 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" , "" )
2026-01-02 15:42:59 +03:00
if clientID == "" || clientSecret == "" || baseURL == "" {
2026-01-02 15:34:01 +03:00
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" )
2026-01-02 15:42:59 +03:00
userID , err := validateOAuthState ( state , a . jwtSecret )
2026-01-02 15:34:01 +03:00
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 ) {
2025-12-31 19:11:28 +03:00
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:38:28 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2026-01-02 15:34:01 +03:00
var todoistEmail sql . NullString
2026-01-02 14:47:51 +03:00
err := a . DB . QueryRow ( `
2026-01-02 15:34:01 +03:00
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 )
2026-01-01 18:38:28 +03:00
return
}
2026-01-02 15:34:01 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-26 18:45:58 +03:00
"connected" : true ,
2026-01-02 15:34:01 +03:00
"todoist_email" : todoistEmail . String ,
} )
}
2026-01-04 19:42:29 +03:00
// ============================================
// Tasks handlers
// ============================================
// 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
}
2026-01-06 14:54:37 +03:00
// Запрос с получением всех необходимых данных для группировки и отображения
2026-01-04 19:42:29 +03:00
query := `
2026-01-06 14:54:37 +03:00
SELECT
t . id ,
t . name ,
t . completed ,
t . last_completed_at ,
2026-01-06 15:56:52 +03:00
t . next_show_at ,
2026-01-06 14:54:37 +03:00
t . repetition_period : : text ,
2026-01-06 16:41:54 +03:00
t . repetition_date ,
2026-01-06 14:54:37 +03:00
t . progression_base ,
2026-01-12 18:58:52 +03:00
t . wishlist_id ,
2026-01-13 18:22:02 +03:00
t . config_id ,
2026-01-13 22:35:01 +03:00
t . reward_policy ,
2026-01-06 14:54:37 +03:00
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 [ ]
2026-01-29 17:47:47 +03:00
) as subtask_project_names ,
COALESCE ( td . auto_complete , FALSE ) as auto_complete
2026-01-06 14:54:37 +03:00
FROM tasks t
2026-01-29 17:47:47 +03:00
LEFT JOIN task_drafts td ON td . task_id = t . id AND td . user_id = $ 1
2026-01-06 14:54:37 +03:00
WHERE t . user_id = $ 1 AND t . parent_task_id IS NULL AND t . deleted = FALSE
2026-01-04 19:42:29 +03:00
ORDER BY
2026-02-03 14:53:45 +03:00
-- Сначала разделяем на невыполненные ( 0 ) и выполненные ( 1 )
2026-01-06 14:54:37 +03:00
CASE WHEN t . last_completed_at IS NULL OR t . last_completed_at : : date < CURRENT_DATE THEN 0 ELSE 1 END ,
2026-02-03 14:53:45 +03:00
-- Для невыполненных : сортируем по 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 , ' 1970 - 01 - 01 ' : : timestamp with time zone )
ELSE ' 1970 - 01 - 01 ' : : timestamp with time zone
END
2026-01-04 19:42:29 +03:00
`
rows , err := a . DB . Query ( query , userID )
if err != nil {
log . Printf ( "Error querying tasks: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error querying tasks: %v" , err ) , http . StatusInternalServerError )
return
}
defer rows . Close ( )
tasks := make ( [ ] Task , 0 )
for rows . Next ( ) {
var task Task
var lastCompletedAt sql . NullString
2026-01-06 15:56:52 +03:00
var nextShowAt sql . NullString
2026-01-04 19:42:29 +03:00
var repetitionPeriod sql . NullString
2026-01-06 16:41:54 +03:00
var repetitionDate sql . NullString
2026-01-06 14:54:37 +03:00
var progressionBase sql . NullFloat64
2026-01-12 18:58:52 +03:00
var wishlistID sql . NullInt64
2026-01-13 18:22:02 +03:00
var configID sql . NullInt64
2026-01-13 22:35:01 +03:00
var rewardPolicy sql . NullString
2026-01-06 14:54:37 +03:00
var projectNames pq . StringArray
var subtaskProjectNames pq . StringArray
2026-01-29 17:47:47 +03:00
var autoComplete bool
2026-01-04 19:42:29 +03:00
2026-01-06 14:54:37 +03:00
err := rows . Scan (
2026-01-26 18:45:58 +03:00
& task . ID ,
& task . Name ,
& task . Completed ,
2026-01-06 15:56:52 +03:00
& lastCompletedAt ,
& nextShowAt ,
2026-01-06 14:54:37 +03:00
& repetitionPeriod ,
2026-01-06 16:41:54 +03:00
& repetitionDate ,
2026-01-06 14:54:37 +03:00
& progressionBase ,
2026-01-12 18:58:52 +03:00
& wishlistID ,
2026-01-13 18:22:02 +03:00
& configID ,
2026-01-13 22:35:01 +03:00
& rewardPolicy ,
2026-01-06 14:54:37 +03:00
& task . SubtasksCount ,
& projectNames ,
& subtaskProjectNames ,
2026-01-29 17:47:47 +03:00
& autoComplete ,
2026-01-06 14:54:37 +03:00
)
2026-01-04 19:42:29 +03:00
if err != nil {
log . Printf ( "Error scanning task: %v" , err )
continue
}
if lastCompletedAt . Valid {
task . LastCompletedAt = & lastCompletedAt . String
}
2026-01-06 15:56:52 +03:00
if nextShowAt . Valid {
task . NextShowAt = & nextShowAt . String
}
2026-01-04 19:42:29 +03:00
if repetitionPeriod . Valid {
task . RepetitionPeriod = & repetitionPeriod . String
}
2026-01-06 16:41:54 +03:00
if repetitionDate . Valid {
task . RepetitionDate = & repetitionDate . String
}
2026-01-06 14:54:37 +03:00
if progressionBase . Valid {
task . HasProgression = true
task . ProgressionBase = & progressionBase . Float64
} else {
task . HasProgression = false
}
2026-01-12 18:58:52 +03:00
if wishlistID . Valid {
wishlistIDInt := int ( wishlistID . Int64 )
task . WishlistID = & wishlistIDInt
}
2026-01-13 18:22:02 +03:00
if configID . Valid {
configIDInt := int ( configID . Int64 )
task . ConfigID = & configIDInt
}
2026-01-13 22:35:01 +03:00
if rewardPolicy . Valid {
task . RewardPolicy = & rewardPolicy . String
}
2026-01-29 17:47:47 +03:00
task . AutoComplete = autoComplete
2026-01-06 14:54:37 +03:00
// Объединяем проекты из основной задачи и подзадач
allProjects := make ( map [ string ] bool )
for _ , pn := range projectNames {
if pn != "" {
allProjects [ pn ] = true
}
}
for _ , pn := range subtaskProjectNames {
if pn != "" {
allProjects [ pn ] = true
}
}
2026-01-26 18:45:58 +03:00
2026-01-06 14:54:37 +03:00
task . ProjectNames = make ( [ ] string , 0 , len ( allProjects ) )
for pn := range allProjects {
task . ProjectNames = append ( task . ProjectNames , pn )
}
2026-01-04 19:42:29 +03:00
tasks = append ( tasks , task )
}
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
2026-01-06 15:56:52 +03:00
var nextShowAt sql . NullString
2026-01-04 19:42:29 +03:00
var repetitionPeriod sql . NullString
2026-01-06 16:41:54 +03:00
var repetitionDate sql . NullString
2026-01-12 18:58:52 +03:00
var wishlistID sql . NullInt64
2026-01-13 18:22:02 +03:00
var configID sql . NullInt64
2026-01-13 22:35:01 +03:00
var rewardPolicy sql . NullString
2026-01-04 19:42:29 +03:00
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
var repetitionPeriodStr string
2026-01-06 16:41:54 +03:00
var repetitionDateStr string
2026-01-04 19:42:29 +03:00
err = a . DB . QueryRow ( `
2026-01-06 15:56:52 +03:00
SELECT id , name , completed , last_completed_at , next_show_at , reward_message , progression_base ,
2026-01-06 16:41:54 +03:00
CASE WHEN repetition_period IS NULL THEN ' ' ELSE repetition_period : : text END as repetition_period ,
2026-01-12 18:58:52 +03:00
COALESCE ( repetition_date , ' ' ) as repetition_date ,
2026-01-13 18:22:02 +03:00
wishlist_id ,
2026-01-13 22:35:01 +03:00
config_id ,
reward_policy
2026-01-04 19:42:29 +03:00
FROM tasks
WHERE id = $ 1 AND user_id = $ 2 AND deleted = FALSE
` , taskID , userID ) . Scan (
2026-01-13 22:35:01 +03:00
& task . ID , & task . Name , & task . Completed , & lastCompletedAt , & nextShowAt , & rewardMessage , & progressionBase , & repetitionPeriodStr , & repetitionDateStr , & wishlistID , & configID , & rewardPolicy ,
2026-01-04 19:42:29 +03:00
)
2026-01-26 18:45:58 +03:00
2026-01-06 16:41:54 +03:00
log . Printf ( "Scanned repetition_period for task %d: String='%s', repetition_date='%s'" , taskID , repetitionPeriodStr , repetitionDateStr )
2026-01-26 18:45:58 +03:00
2026-01-04 19:42:29 +03:00
// Преобразуем в sql.NullString для совместимости
if repetitionPeriodStr != "" {
repetitionPeriod = sql . NullString { String : repetitionPeriodStr , Valid : true }
} else {
repetitionPeriod = sql . NullString { Valid : false }
}
2026-01-06 16:41:54 +03:00
if repetitionDateStr != "" {
repetitionDate = sql . NullString { String : repetitionDateStr , Valid : true }
} else {
repetitionDate = sql . NullString { Valid : false }
}
2026-01-04 19:42:29 +03:00
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
}
2026-01-06 15:56:52 +03:00
if nextShowAt . Valid {
task . NextShowAt = & nextShowAt . String
}
2026-01-04 19:42:29 +03:00
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 )
}
2026-01-06 16:41:54 +03:00
if repetitionDate . Valid && repetitionDate . String != "" {
task . RepetitionDate = & repetitionDate . String
log . Printf ( "Task %d has repetition_date: %s" , task . ID , repetitionDate . String )
}
2026-01-12 18:58:52 +03:00
if wishlistID . Valid {
wishlistIDInt := int ( wishlistID . Int64 )
task . WishlistID = & wishlistIDInt
}
2026-01-13 18:22:02 +03:00
if configID . Valid {
configIDInt := int ( configID . Int64 )
task . ConfigID = & configIDInt
}
2026-01-21 18:57:47 +03:00
if rewardPolicy . Valid {
task . RewardPolicy = & rewardPolicy . String
}
2026-01-04 19:42:29 +03:00
// Получаем награды основной задачи
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
FROM tasks
WHERE parent_task_id = $ 1 AND deleted = FALSE
ORDER BY id
` , taskID )
if err != nil {
log . Printf ( "Error querying subtasks: %v" , err )
} else {
defer subtaskRows . Close ( )
2026-01-25 15:28:37 +03:00
subtaskMap := make ( map [ int ] * Subtask )
subtaskIDs := make ( [ ] int , 0 )
2026-01-26 18:45:58 +03:00
2026-01-04 19:42:29 +03:00
for subtaskRows . Next ( ) {
var subtaskTask Task
var subtaskRewardMessage sql . NullString
var subtaskProgressionBase sql . NullFloat64
var subtaskLastCompletedAt sql . NullString
err := subtaskRows . Scan (
& subtaskTask . ID , & subtaskTask . Name , & subtaskTask . Completed ,
& subtaskLastCompletedAt , & subtaskRewardMessage , & subtaskProgressionBase ,
)
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
}
2026-01-25 15:28:37 +03:00
subtaskIDs = append ( subtaskIDs , subtaskTask . ID )
subtask := Subtask {
Task : subtaskTask ,
Rewards : make ( [ ] Reward , 0 ) ,
}
subtaskMap [ subtaskTask . ID ] = & subtask
}
// Загружаем все награды всех подзадач одним запросом
if len ( subtaskIDs ) > 0 {
2026-01-26 18:17:56 +03:00
// Используем параметризованный запрос с ANY(ARRAY[...])
query := `
2026-01-25 15:28:37 +03:00
SELECT rc . task_id , rc . id , rc . position , p . name AS project_name , rc . value , rc . use_progression
2026-01-04 19:42:29 +03:00
FROM reward_configs rc
JOIN projects p ON rc . project_id = p . id
2026-01-26 18:17:56 +03:00
WHERE rc . task_id = ANY ( $ 1 : : int [ ] )
2026-01-25 15:28:37 +03:00
ORDER BY rc . task_id , rc . position
2026-01-26 18:17:56 +03:00
`
2026-01-04 19:42:29 +03:00
2026-01-26 18:17:56 +03:00
subtaskRewardRows , err := a . DB . Query ( query , pq . Array ( subtaskIDs ) )
2026-01-25 15:28:37 +03:00
if err != nil {
log . Printf ( "Error querying subtask rewards: %v" , err )
} else {
defer subtaskRewardRows . Close ( )
2026-01-04 19:42:29 +03:00
for subtaskRewardRows . Next ( ) {
2026-01-25 15:28:37 +03:00
var taskID int
2026-01-04 19:42:29 +03:00
var reward Reward
2026-01-25 15:28:37 +03:00
err := subtaskRewardRows . Scan ( & taskID , & reward . ID , & reward . Position , & reward . ProjectName , & reward . Value , & reward . UseProgression )
2026-01-04 19:42:29 +03:00
if err != nil {
log . Printf ( "Error scanning subtask reward: %v" , err )
continue
}
2026-01-25 15:28:37 +03:00
if subtask , exists := subtaskMap [ taskID ] ; exists {
subtask . Rewards = append ( subtask . Rewards , reward )
}
2026-01-04 19:42:29 +03:00
}
}
2026-01-25 15:28:37 +03:00
}
2026-01-04 19:42:29 +03:00
2026-01-25 15:28:37 +03:00
// Преобразуем map в slice, сохраняя порядок по ID
for _ , id := range subtaskIDs {
if subtask , exists := subtaskMap [ id ] ; exists {
subtasks = append ( subtasks , * subtask )
}
2026-01-04 19:42:29 +03:00
}
}
2026-01-29 17:47:47 +03:00
// Инициализируем auto_complete значением по умолчанию
task . AutoComplete = false
2026-01-25 15:28:37 +03:00
2026-01-28 20:19:53 +03:00
// Загружаем данные из драфта, если он существует
var draftProgressionValue sql . NullFloat64
2026-01-29 17:47:47 +03:00
var draftAutoComplete sql . NullBool
var draftProgressionValuePtr * float64
var draftSubtasks [ ] DraftSubtask
2026-01-28 20:19:53 +03:00
err = a . DB . QueryRow ( `
2026-01-29 17:47:47 +03:00
SELECT progression_value , auto_complete
2026-01-28 20:19:53 +03:00
FROM task_drafts
WHERE task_id = $ 1 AND user_id = $ 2
2026-01-29 17:47:47 +03:00
` , taskID , userID ) . Scan ( & draftProgressionValue , & draftAutoComplete )
2026-01-28 20:19:53 +03:00
if err == nil {
// Драфт существует, загружаем данные
if draftProgressionValue . Valid {
2026-01-29 17:47:47 +03:00
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 )
2026-01-28 20:19:53 +03:00
}
// Загружаем подзадачи из драфта
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 ( )
2026-01-29 17:47:47 +03:00
draftSubtasks = make ( [ ] DraftSubtask , 0 )
2026-01-28 20:19:53 +03:00
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 )
2026-01-29 17:47:47 +03:00
} 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 )
}
2026-01-28 20:19:53 +03:00
}
2026-01-13 18:22:02 +03:00
// Если задача - тест (есть 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 )
2026-01-26 18:45:58 +03:00
2026-01-13 18:22:02 +03:00
if err == nil {
response . WordsCount = & wordsCount
if maxCards . Valid {
maxCardsInt := int ( maxCards . Int64 )
response . MaxCards = & maxCardsInt
}
2026-01-26 18:45:58 +03:00
2026-01-13 18:22:02 +03:00
// Загружаем связанные словари
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 )
}
}
2026-01-29 17:47:47 +03:00
log . Printf ( "Task %d: Sending response with auto_complete = %v (task.AutoComplete = %v)" , taskID , response . Task . AutoComplete , task . AutoComplete )
2026-01-04 19:42:29 +03:00
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 )
2026-01-26 18:45:58 +03:00
2026-01-04 19:42:29 +03:00
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 )
}
2026-01-26 18:45:58 +03:00
2026-01-04 19:42:29 +03:00
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 )
2026-01-26 18:45:58 +03:00
2026-01-04 19:42:29 +03:00
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 )
}
2026-01-26 18:45:58 +03:00
2026-01-04 19:42:29 +03:00
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
}
}
2026-01-29 16:00:17 +03:00
// Валидация wishlist_id: если указан, проверяем что желание существует и пользователь имеет доступ
2026-01-12 18:58:52 +03:00
var wishlistName string
if req . WishlistID != nil {
var wishlistOwnerID int
2026-01-29 16:00:17 +03:00
var authorID sql . NullInt64
var boardID sql . NullInt64
2026-01-12 18:58:52 +03:00
err := a . DB . QueryRow ( `
2026-01-29 16:00:17 +03:00
SELECT user_id , name , author_id , board_id FROM wishlist_items
2026-01-12 18:58:52 +03:00
WHERE id = $ 1 AND deleted = FALSE
2026-01-29 16:00:17 +03:00
` , * req . WishlistID ) . Scan ( & wishlistOwnerID , & wishlistName , & authorID , & boardID )
2026-01-26 18:45:58 +03:00
2026-01-12 18:58:52 +03:00
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
}
2026-01-26 18:45:58 +03:00
2026-01-29 16:00:17 +03:00
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 {
2026-01-12 18:58:52 +03:00
sendErrorWithCORS ( w , "Wishlist item not found" , http . StatusNotFound )
return
}
2026-01-26 18:45:58 +03:00
2026-01-30 19:53:13 +03:00
// Проверяем, что нет другой активной (не удаленной и не выполненной) задачи с таким wishlist_id для этого пользователя
// Если задача была выполнена (completed > 0) или удалена, можно создать новую
2026-01-12 18:58:52 +03:00
var existingTaskID int
2026-01-30 19:53:13 +03:00
var existingTaskCompleted int
2026-01-12 18:58:52 +03:00
err = a . DB . QueryRow ( `
2026-01-30 19:53:13 +03:00
SELECT id , completed FROM tasks
WHERE wishlist_id = $ 1 AND user_id = $ 2 AND deleted = FALSE
` , * req . WishlistID , userID ) . Scan ( & existingTaskID , & existingTaskCompleted )
2026-01-26 18:45:58 +03:00
2026-01-12 18:58:52 +03:00
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
}
2026-01-30 19:53:13 +03:00
// Если задача была выполнена (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
}
2026-01-12 18:58:52 +03:00
}
2026-01-26 18:45:58 +03:00
2026-01-12 18:58:52 +03:00
// Если название задачи не указано или пустое, используем название желания
if strings . TrimSpace ( req . Name ) == "" {
req . Name = wishlistName
}
2026-01-26 18:45:58 +03:00
2026-01-12 18:58:52 +03:00
// Если сообщение награды не указано или пустое, устанавливаем "Выполнить желание: {TITLE}"
if req . RewardMessage == nil || strings . TrimSpace ( * req . RewardMessage ) == "" {
rewardMsg := fmt . Sprintf ( "Выполнить желание: %s" , wishlistName )
req . RewardMessage = & rewardMsg
}
2026-01-26 18:45:58 +03:00
2026-01-12 18:58:52 +03:00
// Задачи, привязанные к желанию, не могут быть периодическими
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
}
}
2026-01-26 18:45:58 +03:00
2026-01-12 18:58:52 +03:00
// Задачи, привязанные к желанию, не могут иметь прогрессию
if req . ProgressionBase != nil {
sendErrorWithCORS ( w , "Tasks linked to wishlist items cannot have progression" , http . StatusBadRequest )
return
}
}
2026-01-04 19:42:29 +03:00
// Начинаем транзакцию
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
2026-01-06 16:41:54 +03:00
var repetitionDate sql . NullString
2026-01-04 19:42:29 +03:00
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 )
}
2026-01-06 16:41:54 +03:00
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 )
}
2026-01-04 19:42:29 +03:00
// Используем CAST для преобразования строки в INTERVAL
var repetitionPeriodValue interface { }
if repetitionPeriod . Valid {
repetitionPeriodValue = repetitionPeriod . String
} else {
repetitionPeriodValue = nil
}
2026-01-12 18:58:52 +03:00
// Подготовка 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" )
}
2026-01-13 22:35:01 +03:00
// Подготовка 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 для задач, не связанных с желаниями
}
2026-01-04 19:42:29 +03:00
// Используем условный SQL для обработки NULL значений
var insertSQL string
var insertArgs [ ] interface { }
if repetitionPeriod . Valid {
2026-01-09 13:35:46 +03:00
// Для repetition_period выставляем сегодняшнюю дату
2026-01-19 22:06:46 +03:00
// Получаем часовой пояс из переменной окружения (по умолчанию 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 )
2026-01-09 13:35:46 +03:00
insertSQL = `
2026-01-13 22:35:01 +03:00
INSERT INTO tasks ( user_id , name , reward_message , progression_base , repetition_period , repetition_date , next_show_at , completed , deleted , wishlist_id , reward_policy )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 : : INTERVAL , NULL , $ 6 , 0 , FALSE , $ 7 , $ 8 )
2026-01-09 13:35:46 +03:00
RETURNING id
`
2026-01-13 22:35:01 +03:00
insertArgs = [ ] interface { } { userID , strings . TrimSpace ( req . Name ) , rewardMessage , progressionBase , repetitionPeriodValue , now , wishlistIDValue , rewardPolicyValue }
2026-01-06 16:41:54 +03:00
} else if repetitionDate . Valid {
// Вычисляем next_show_at для задачи с repetition_date
2026-01-19 22:06:46 +03:00
// Получаем часовой пояс из переменной окружения (по умолчанию 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 ) )
2026-01-06 16:41:54 +03:00
if nextShowAt != nil {
insertSQL = `
2026-01-13 22:35:01 +03:00
INSERT INTO tasks ( user_id , name , reward_message , progression_base , repetition_period , repetition_date , next_show_at , completed , deleted , wishlist_id , reward_policy )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , NULL , $ 5 , $ 6 , 0 , FALSE , $ 7 , $ 8 )
2026-01-06 16:41:54 +03:00
RETURNING id
`
2026-01-13 22:35:01 +03:00
insertArgs = [ ] interface { } { userID , strings . TrimSpace ( req . Name ) , rewardMessage , progressionBase , repetitionDate . String , nextShowAt , wishlistIDValue , rewardPolicyValue }
2026-01-06 16:41:54 +03:00
} else {
insertSQL = `
2026-01-13 22:35:01 +03:00
INSERT INTO tasks ( user_id , name , reward_message , progression_base , repetition_period , repetition_date , completed , deleted , wishlist_id , reward_policy )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , NULL , $ 5 , 0 , FALSE , $ 6 , $ 7 )
2026-01-06 16:41:54 +03:00
RETURNING id
`
2026-01-13 22:35:01 +03:00
insertArgs = [ ] interface { } { userID , strings . TrimSpace ( req . Name ) , rewardMessage , progressionBase , repetitionDate . String , wishlistIDValue , rewardPolicyValue }
2026-01-06 16:41:54 +03:00
}
2026-01-04 19:42:29 +03:00
} else {
insertSQL = `
2026-01-13 22:35:01 +03:00
INSERT INTO tasks ( user_id , name , reward_message , progression_base , repetition_period , repetition_date , completed , deleted , wishlist_id , reward_policy )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , NULL , NULL , 0 , FALSE , $ 5 , $ 6 )
2026-01-04 19:42:29 +03:00
RETURNING id
`
2026-01-13 22:35:01 +03:00
insertArgs = [ ] interface { } { userID , strings . TrimSpace ( req . Name ) , rewardMessage , progressionBase , wishlistIDValue , rewardPolicyValue }
2026-01-04 19:42:29 +03:00
}
err = tx . QueryRow ( insertSQL , insertArgs ... ) . Scan ( & taskID )
if err != nil {
log . Printf ( "Error creating task: %v" , err )
2026-01-30 19:53:13 +03:00
// Проверяем, не является ли это ошибкой уникального индекса
if strings . Contains ( err . Error ( ) , "unique" ) || strings . Contains ( err . Error ( ) , "duplicate" ) {
sendErrorWithCORS ( w , "Task already exists for this wishlist item" , http . StatusBadRequest )
return
}
2026-01-04 19:42:29 +03:00
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 _ , subtaskReq := range req . Subtasks {
var subtaskName sql . NullString
var subtaskRewardMessage sql . NullString
var subtaskProgressionBase sql . NullFloat64
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 }
}
var subtaskID int
err = tx . QueryRow ( `
INSERT INTO tasks ( user_id , name , parent_task_id , reward_message , progression_base , completed , deleted )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , 0 , FALSE )
RETURNING id
` , userID , subtaskName , taskID , subtaskRewardMessage , subtaskProgressionBase ) . 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
}
}
}
2026-01-13 18:22:02 +03:00
// Если это тест, создаем конфигурацию
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 )
}
2026-01-04 19:42:29 +03:00
// Коммитим транзакцию
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
}
// Возвращаем созданную задачу
var createdTask Task
var lastCompletedAt sql . NullString
var createdRepetitionPeriod sql . NullString
2026-01-06 16:41:54 +03:00
var createdRepetitionDate sql . NullString
2026-01-04 19:42:29 +03:00
err = a . DB . QueryRow ( `
2026-01-06 16:41:54 +03:00
SELECT id , name , completed , last_completed_at , reward_message , progression_base , repetition_period : : text , repetition_date
2026-01-04 19:42:29 +03:00
FROM tasks
WHERE id = $ 1
` , taskID ) . Scan (
& createdTask . ID , & createdTask . Name , & createdTask . Completed ,
2026-01-06 16:41:54 +03:00
& lastCompletedAt , & rewardMessage , & progressionBase , & createdRepetitionPeriod , & createdRepetitionDate ,
2026-01-04 19:42:29 +03:00
)
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
}
2026-01-06 16:41:54 +03:00
if createdRepetitionDate . Valid {
createdTask . RepetitionDate = & createdRepetitionDate . String
}
2026-01-04 19:42:29 +03:00
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
}
}
2026-01-12 18:58:52 +03:00
// Обработка 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 ( & currentWishlistID )
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
}
}
2026-01-26 18:45:58 +03:00
2026-01-12 18:58:52 +03:00
// Задачи, привязанные к желанию, не могут иметь прогрессию
if req . ProgressionBase != nil {
sendErrorWithCORS ( w , "Tasks linked to wishlist items cannot have progression" , http . StatusBadRequest )
return
}
}
2026-01-04 19:42:29 +03:00
// Начинаем транзакцию
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
2026-01-06 16:41:54 +03:00
var repetitionDate sql . NullString
2026-01-04 19:42:29 +03:00
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 )
}
2026-01-06 16:41:54 +03:00
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 )
}
2026-01-04 19:42:29 +03:00
2026-01-13 22:35:01 +03:00
// Подготовка reward_policy: если задача связана с желанием и политика не указана, используем "personal" по умолчанию
var rewardPolicyValue interface { }
if newWishlistID != nil {
2026-01-21 18:57:47 +03:00
// Если reward_policy явно указан в запросе, используем е г о
2026-01-13 22:35:01 +03:00
if req . RewardPolicy != nil && ( * req . RewardPolicy == "personal" || * req . RewardPolicy == "general" ) {
rewardPolicyValue = * req . RewardPolicy
2026-01-21 18:57:47 +03:00
} else if req . RewardPolicy == nil {
// Если reward_policy не указан в запросе (undefined), сохраняем текущее значение из БД
// Это важно для случаев, когда обновляются другие поля, но reward_policy не должен меняться
2026-01-13 22:35:01 +03:00
var currentRewardPolicy sql . NullString
err = a . DB . QueryRow ( "SELECT reward_policy FROM tasks WHERE id = $1" , taskID ) . Scan ( & currentRewardPolicy )
if err == nil && currentRewardPolicy . Valid {
rewardPolicyValue = currentRewardPolicy . String
} else {
2026-01-21 18:57:47 +03:00
// Если в БД нет значения, используем "personal" по умолчанию
rewardPolicyValue = "personal"
2026-01-13 22:35:01 +03:00
}
}
} else {
rewardPolicyValue = nil // NULL для задач, не связанных с желаниями
}
2026-01-04 19:42:29 +03:00
// Используем условный SQL для обработки NULL значений
var updateSQL string
var updateArgs [ ] interface { }
2026-01-19 22:06:46 +03:00
// Получаем часовой пояс из переменной окружения (по умолчанию 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
}
2026-01-04 19:42:29 +03:00
if repetitionPeriod . Valid {
2026-01-09 13:35:46 +03:00
// Для repetition_period выставляем сегодняшнюю дату
2026-01-19 22:06:46 +03:00
now := time . Now ( ) . In ( loc )
2026-01-09 13:35:46 +03:00
updateSQL = `
UPDATE tasks
2026-01-13 22:35:01 +03:00
SET name = $ 1 , reward_message = $ 2 , progression_base = $ 3 , repetition_period = $ 4 : : INTERVAL , repetition_date = NULL , next_show_at = $ 5 , wishlist_id = $ 6 , reward_policy = $ 7
WHERE id = $ 8
2026-01-09 13:35:46 +03:00
`
2026-01-13 22:35:01 +03:00
updateArgs = [ ] interface { } { strings . TrimSpace ( req . Name ) , rewardMessage , progressionBase , repetitionPeriod . String , now , newWishlistID , rewardPolicyValue , taskID }
2026-01-06 16:41:54 +03:00
} else if repetitionDate . Valid {
// Вычисляем next_show_at для задачи с repetition_date
2026-01-19 22:06:46 +03:00
nextShowAt := calculateNextShowAtFromRepetitionDate ( repetitionDate . String , time . Now ( ) . In ( loc ) )
2026-01-06 16:41:54 +03:00
if nextShowAt != nil {
updateSQL = `
UPDATE tasks
2026-01-13 22:35:01 +03:00
SET name = $ 1 , reward_message = $ 2 , progression_base = $ 3 , repetition_period = NULL , repetition_date = $ 4 , next_show_at = $ 5 , wishlist_id = $ 6 , reward_policy = $ 7
WHERE id = $ 8
2026-01-06 16:41:54 +03:00
`
2026-01-13 22:35:01 +03:00
updateArgs = [ ] interface { } { strings . TrimSpace ( req . Name ) , rewardMessage , progressionBase , repetitionDate . String , nextShowAt , newWishlistID , rewardPolicyValue , taskID }
2026-01-06 16:41:54 +03:00
} else {
updateSQL = `
UPDATE tasks
2026-01-13 22:35:01 +03:00
SET name = $ 1 , reward_message = $ 2 , progression_base = $ 3 , repetition_period = NULL , repetition_date = $ 4 , wishlist_id = $ 5 , reward_policy = $ 6
WHERE id = $ 7
2026-01-06 16:41:54 +03:00
`
2026-01-13 22:35:01 +03:00
updateArgs = [ ] interface { } { strings . TrimSpace ( req . Name ) , rewardMessage , progressionBase , repetitionDate . String , newWishlistID , rewardPolicyValue , taskID }
2026-01-06 16:41:54 +03:00
}
2026-01-04 19:42:29 +03:00
} else {
updateSQL = `
UPDATE tasks
2026-01-13 22:35:01 +03:00
SET name = $ 1 , reward_message = $ 2 , progression_base = $ 3 , repetition_period = NULL , repetition_date = NULL , next_show_at = NULL , wishlist_id = $ 4 , reward_policy = $ 5
WHERE id = $ 6
2026-01-04 19:42:29 +03:00
`
2026-01-13 22:35:01 +03:00
updateArgs = [ ] interface { } { strings . TrimSpace ( req . Name ) , rewardMessage , progressionBase , newWishlistID , rewardPolicyValue , taskID }
2026-01-04 19:42:29 +03:00
}
_ , 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 _ , subtaskReq := range req . Subtasks {
if subtaskReq . ID != nil {
subtaskIDsInRequest [ * subtaskReq . ID ] = true
// Обновляем существующую подзадачу
var subtaskName sql . NullString
var subtaskRewardMessage sql . NullString
var subtaskProgressionBase sql . NullFloat64
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 }
}
_ , err = tx . Exec ( `
UPDATE tasks
SET name = $ 1 , reward_message = $ 2 , progression_base = $ 3
WHERE id = $ 4 AND parent_task_id = $ 5
` , subtaskName , subtaskRewardMessage , subtaskProgressionBase , * 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
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 }
}
var subtaskID int
err = tx . QueryRow ( `
INSERT INTO tasks ( user_id , name , parent_task_id , reward_message , progression_base , completed , deleted )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , 0 , FALSE )
RETURNING id
` , userID , subtaskName , taskID , subtaskRewardMessage , subtaskProgressionBase ) . 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
}
}
}
2026-01-13 18:22:02 +03:00
// Получаем текущий config_id задачи
var currentConfigID sql . NullInt64
err = tx . QueryRow ( "SELECT config_id FROM tasks WHERE id = $1" , taskID ) . Scan ( & currentConfigID )
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
}
}
2026-01-04 19:42:29 +03:00
// Коммитим транзакцию
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
}
// Возвращаем обновленную задачу
var updatedTask Task
var lastCompletedAt sql . NullString
var updatedRepetitionPeriod sql . NullString
2026-01-06 16:41:54 +03:00
var updatedRepetitionDate sql . NullString
2026-01-04 19:42:29 +03:00
err = a . DB . QueryRow ( `
2026-01-06 16:41:54 +03:00
SELECT id , name , completed , last_completed_at , reward_message , progression_base , repetition_period : : text , repetition_date
2026-01-04 19:42:29 +03:00
FROM tasks
WHERE id = $ 1
` , taskID ) . Scan (
& updatedTask . ID , & updatedTask . Name , & updatedTask . Completed ,
2026-01-06 16:41:54 +03:00
& lastCompletedAt , & rewardMessage , & progressionBase , & updatedRepetitionPeriod , & updatedRepetitionDate ,
2026-01-04 19:42:29 +03:00
)
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
}
2026-01-06 16:41:54 +03:00
if updatedRepetitionDate . Valid {
updatedTask . RepetitionDate = & updatedRepetitionDate . String
}
2026-01-04 19:42:29 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( updatedTask )
}
2026-01-28 20:19:53 +03:00
// saveTaskDraftHandler сохраняет или обновляет драфт задачи
func ( a * App ) saveTaskDraftHandler ( w http . ResponseWriter , r * http . Request ) {
2026-01-04 19:42:29 +03:00
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
}
2026-01-28 20:19:53 +03:00
// Проверяем владельца задачи
2026-01-04 19:42:29 +03:00
var ownerID int
2026-01-28 20:19:53 +03:00
err = a . DB . QueryRow ( "SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE" , taskID ) . Scan ( & ownerID )
2026-01-04 19:42:29 +03:00
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
}
2026-01-28 20:19:53 +03:00
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
}
// Начинаем транзакцию
tx , err := a . DB . Begin ( )
2026-01-04 19:42:29 +03:00
if err != nil {
2026-01-28 20:19:53 +03:00
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
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 если параметр false
autoComplete := req . AutoComplete
_ , err = tx . Exec ( `
UPDATE task_drafts
SET progression_value = $ 1 , auto_complete = $ 2 , updated_at = NOW ( )
WHERE id = $ 3
` , progressionValue , autoComplete , draftID )
if err != nil {
log . Printf ( "Error updating draft: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error updating draft: %v" , err ) , http . StatusInternalServerError )
return
}
// Удаляем все старые записи подзадач
_ , 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 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 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 req . ChildrenTaskIDs {
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 )
2026-01-04 19:42:29 +03:00
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"success" : true ,
2026-01-28 20:19:53 +03:00
"message" : "Draft saved successfully" ,
2026-01-04 19:42:29 +03:00
} )
}
2026-01-28 20:19:53 +03:00
// deleteTaskHandler удаляет задачу (помечает как deleted)
func ( a * App ) deleteTaskHandler ( w http . ResponseWriter , r * http . Request ) {
2026-01-04 19:42:29 +03:00
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
}
2026-01-28 20:19:53 +03:00
// Проверяем владельца
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 )
2026-01-04 19:42:29 +03:00
return
}
2026-01-28 20:19:53 +03:00
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 )
// Н е возвращаем ошибку, продолжаем выполнение
}
2026-01-04 19:42:29 +03:00
// Получаем задачу и проверяем владельца
var task Task
var rewardMessage sql . NullString
var progressionBase sql . NullFloat64
var repetitionPeriod sql . NullString
2026-01-06 16:41:54 +03:00
var repetitionDate sql . NullString
2026-01-04 19:42:29 +03:00
var ownerID int
2026-01-12 18:58:52 +03:00
var wishlistID sql . NullInt64
2026-01-04 19:42:29 +03:00
err = a . DB . QueryRow ( `
2026-01-12 18:58:52 +03:00
SELECT id , name , reward_message , progression_base , repetition_period : : text , repetition_date , user_id , wishlist_id
2026-01-04 19:42:29 +03:00
FROM tasks
WHERE id = $ 1 AND deleted = FALSE
2026-01-12 18:58:52 +03:00
` , taskID ) . Scan ( & task . ID , & task . Name , & rewardMessage , & progressionBase , & repetitionPeriod , & repetitionDate , & ownerID , & wishlistID )
2026-01-04 19:42:29 +03:00
if err == sql . ErrNoRows {
2026-01-28 20:19:53 +03:00
return fmt . Errorf ( "task not found" )
2026-01-04 19:42:29 +03:00
}
if err != nil {
log . Printf ( "Error querying task: %v" , err )
2026-01-28 20:19:53 +03:00
return fmt . Errorf ( "error querying task: %v" , err )
2026-01-04 19:42:29 +03:00
}
if ownerID != userID {
2026-01-28 20:19:53 +03:00
return fmt . Errorf ( "task not found" )
2026-01-04 19:42:29 +03:00
}
2026-01-12 18:58:52 +03:00
// Проверяем, что желание разблокировано (если задача связана с желанием)
if wishlistID . Valid {
unlocked , err := a . checkWishlistUnlock ( int ( wishlistID . Int64 ) , userID )
if err != nil {
log . Printf ( "Error checking wishlist unlock status: %v" , err )
2026-01-28 20:19:53 +03:00
return fmt . Errorf ( "error checking wishlist unlock status: %v" , err )
2026-01-12 18:58:52 +03:00
}
if ! unlocked {
2026-01-28 20:19:53 +03:00
return fmt . Errorf ( "cannot complete task: wishlist item is not unlocked" )
2026-01-12 18:58:52 +03:00
}
}
2026-01-04 19:42:29 +03:00
// Валидация: если progression_base != null, то value обязателен
if progressionBase . Valid && req . Value == nil {
2026-01-28 20:19:53 +03:00
return fmt . Errorf ( "value is required when progression_base is set" )
2026-01-04 19:42:29 +03:00
}
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 )
2026-01-28 20:19:53 +03:00
return fmt . Errorf ( "error querying rewards: %v" , err )
2026-01-04 19:42:29 +03:00
}
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 )
for _ , reward := range rewards {
var score float64
if reward . UseProgression && progressionBase . Valid && req . Value != nil {
score = ( * req . Value / progressionBase . Float64 ) * reward . Value
} else {
score = reward . Value
}
// Формируем строку награды
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
}
2026-01-06 15:00:42 +03:00
// Функция для замены плейсхолдеров в сообщении награды
replaceRewardPlaceholders := func ( message string , rewardStrings map [ int ] 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 )
}
}
// Заменяем ${0}, ${1}, и т.д.
2026-01-04 19:42:29 +03:00
for i := 0 ; i < 100 ; i ++ { // Максимум 100 плейсхолдеров
placeholder := fmt . Sprintf ( "${%d}" , i )
if rewardStr , ok := rewardStrings [ i ] ; ok {
2026-01-06 15:00:42 +03:00
result = strings . ReplaceAll ( result , placeholder , rewardStr )
}
}
// Затем заменяем $0, $1, и т.д. (экранированные уже защищены маркерами)
2026-01-06 16:50:11 +03:00
// Ищем $N, где после N не идет еще одна цифра (чтобы не заменить $10 при поиске $1)
// Go regexp не поддерживает lookahead, поэтому заменяем с конца (от больших чисел к меньшим)
for i := 99 ; i >= 0 ; i -- {
2026-01-06 15:00:42 +03:00
if rewardStr , ok := rewardStrings [ i ] ; ok {
2026-01-06 16:50:11 +03:00
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
}
}
2026-01-04 19:42:29 +03:00
}
}
2026-01-06 15:00:42 +03:00
// Восстанавливаем экранированные доллары из временных маркеров
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 )
2026-01-04 19:42:29 +03:00
} 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 {
defer subtaskRows . Close ( )
for subtaskRows . Next ( ) {
var subtaskID int
var subtaskName string
var subtaskRewardMessage sql . NullString
var subtaskProgressionBase sql . NullFloat64
err := subtaskRows . Scan ( & subtaskID , & subtaskName , & subtaskRewardMessage , & subtaskProgressionBase )
if err != nil {
log . Printf ( "Error scanning subtask: %v" , err )
continue
}
// Пропускаем подзадачи с пустым reward_message
if ! subtaskRewardMessage . Valid || subtaskRewardMessage . String == "" {
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: %v" , err )
continue
}
subtaskRewards := make ( [ ] Reward , 0 )
for subtaskRewardRows . Next ( ) {
var reward Reward
err := subtaskRewardRows . Scan ( & reward . Position , & reward . ProjectName , & reward . Value , & reward . UseProgression )
if err != nil {
log . Printf ( "Error scanning subtask reward: %v" , err )
continue
}
subtaskRewards = append ( subtaskRewards , reward )
}
subtaskRewardRows . Close ( )
// Вычисляем score для наград подзадачи
subtaskRewardStrings := make ( map [ int ] string )
for _ , reward := range subtaskRewards {
var score float64
if reward . UseProgression && subtaskProgressionBase . Valid && req . Value != nil {
score = ( * req . Value / subtaskProgressionBase . Float64 ) * reward . Value
} else if reward . UseProgression && progressionBase . Valid && req . Value != nil {
// Если у подзадачи нет progression_base, используем основной
score = ( * req . Value / progressionBase . Float64 ) * reward . Value
} else {
score = reward . Value
}
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
}
// Подставляем в reward_message подзадачи
2026-01-06 15:00:42 +03:00
subtaskMessage := replaceRewardPlaceholders ( subtaskRewardMessage . String , subtaskRewardStrings )
2026-01-04 19:42:29 +03:00
subtaskMessages = append ( subtaskMessages , subtaskMessage )
}
}
}
// Формируем итоговое сообщение
var finalMessage strings . Builder
finalMessage . WriteString ( mainTaskMessage )
for _ , subtaskMsg := range subtaskMessages {
finalMessage . WriteString ( "\n + " )
finalMessage . WriteString ( subtaskMsg )
}
// Отправляем сообщение через processMessage
userIDPtr := & userID
_ , err = a . processMessage ( finalMessage . String ( ) , userIDPtr )
if err != nil {
// Логируем ошибку, но не откатываем транзакцию
log . Printf ( "Error sending message to Telegram: %v" , err )
}
// Обновляем completed и last_completed_at для основной задачи
2026-01-06 16:41:54 +03:00
// Если repetition_date установлен, вычисляем next_show_at
// Если repetition_period не установлен и repetition_date не установлен, помечаем задачу как удаленную
2026-01-04 19:42:29 +03:00
// Если repetition_period = "0 day" (или любое значение с 0), не обновляем last_completed_at
2026-01-26 18:45:58 +03:00
2026-01-06 16:50:11 +03:00
// Проверяем наличие repetition_date (используем COALESCE, поэтому пустая строка означает отсутствие)
hasRepetitionDate := repetitionDate . Valid && strings . TrimSpace ( repetitionDate . String ) != ""
2026-01-26 18:45:58 +03:00
2026-01-06 16:50:11 +03:00
if hasRepetitionDate {
2026-01-06 16:41:54 +03:00
// Есть repetition_date - вычисляем следующую дату показа
2026-01-19 22:06:46 +03:00
// Получаем часовой пояс из переменной окружения (по умолчанию 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 ) )
2026-01-06 16:41:54 +03:00
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 {
2026-01-04 19:42:29 +03:00
// Проверяем, является ли период нулевым (начинается с "0 ")
periodStr := strings . TrimSpace ( repetitionPeriod . String )
isZeroPeriod := strings . HasPrefix ( periodStr , "0 " ) || periodStr == "0"
2026-01-26 18:45:58 +03:00
2026-01-04 19:42:29 +03:00
if isZeroPeriod {
// Период = 0: обновляем только счетчик, но не last_completed_at
// Задача никогда не будет переноситься в выполненные
_ , err = a . DB . Exec ( `
UPDATE tasks
2026-01-06 15:56:52 +03:00
SET completed = completed + 1 , next_show_at = NULL
2026-01-04 19:42:29 +03:00
WHERE id = $ 1
` , taskID )
} else {
2026-01-07 15:31:40 +03:00
// Обычный период: обновляем счетчик и last_completed_at, вычисляем next_show_at
// next_show_at = last_completed_at + repetition_period
2026-01-19 22:06:46 +03:00
// Получаем часовой пояс из переменной окружения (по умолчанию 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 )
2026-01-07 15:31:40 +03:00
nextShowAt := calculateNextShowAtFromRepetitionPeriod ( repetitionPeriod . String , now )
if nextShowAt != nil {
2026-01-11 15:09:32 +03:00
log . Printf ( "Calculated next_show_at for task %d: %v" , taskID , * nextShowAt )
2026-01-07 15:31:40 +03:00
_ , err = a . DB . Exec ( `
UPDATE tasks
SET completed = completed + 1 , last_completed_at = NOW ( ) , next_show_at = $ 2
WHERE id = $ 1
` , taskID , nextShowAt )
} else {
2026-01-11 15:09:32 +03:00
log . Printf ( "Failed to calculate next_show_at for task %d: repetition_period='%s' returned nil" , taskID , repetitionPeriod . String )
2026-01-07 15:31:40 +03:00
// Если не удалось вычислить дату, обновляем как обычно
_ , err = a . DB . Exec ( `
UPDATE tasks
SET completed = completed + 1 , last_completed_at = NOW ( ) , next_show_at = NULL
WHERE id = $ 1
` , taskID )
}
2026-01-04 19:42:29 +03:00
}
} else {
_ , err = a . DB . Exec ( `
UPDATE tasks
2026-01-06 15:56:52 +03:00
SET completed = completed + 1 , last_completed_at = NOW ( ) , next_show_at = NULL , deleted = TRUE
2026-01-04 19:42:29 +03:00
WHERE id = $ 1
` , taskID )
}
if err != nil {
log . Printf ( "Error updating task completion: %v" , err )
2026-01-28 20:19:53 +03:00
return fmt . Errorf ( "error updating task completion: %v" , err )
2026-01-04 19:42:29 +03:00
}
// Обновляем выбранные подзадачи
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 )
// Н е возвращаем ошибку, основная задача уже обновлена
}
}
2026-01-13 22:35:01 +03:00
// Если задача связана с желанием, завершаем желание и обрабатываем политику награждения
2026-01-12 18:58:52 +03:00
if wishlistID . Valid {
// Завершаем желание
_ , completeErr := a . DB . Exec ( `
UPDATE wishlist_items
2026-01-13 22:35:01 +03:00
SET completed = TRUE , updated_at = NOW ( )
WHERE id = $ 1 AND completed = FALSE
` , wishlistID . Int64 )
2026-01-12 18:58:52 +03:00
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 )
2026-01-13 22:35:01 +03:00
// Обрабатываем политику награждения для всех задач, связанных с этим желанием
2026-01-30 19:53:13 +03:00
// Исключаем задачу, которая была закрыта (taskID), чтобы не обрабатывать её повторно
a . processWishlistRewardPolicy ( int ( wishlistID . Int64 ) , taskID )
2026-01-12 18:58:52 +03:00
}
}
2026-01-28 20:19:53 +03:00
return nil
2026-01-04 19:42:29 +03:00
}
2026-01-28 20:19:53 +03:00
// completeTaskAtEndOfDayHandler устанавливает автовыполнение задачи в конце дня
func ( a * App ) completeTaskAtEndOfDayHandler ( w http . ResponseWriter , r * http . Request ) {
2026-01-10 19:17:03 +03:00
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
}
2026-01-28 20:19:53 +03:00
// Проверяем владельца задачи
2026-01-10 19:17:03 +03:00
var ownerID int
2026-01-28 20:19:53 +03:00
err = a . DB . QueryRow ( "SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE" , taskID ) . Scan ( & ownerID )
if err == sql . ErrNoRows || ownerID != userID {
2026-01-10 19:17:03 +03:00
sendErrorWithCORS ( w , "Task not found" , http . StatusNotFound )
return
}
if err != nil {
2026-01-28 20:19:53 +03:00
log . Printf ( "Error checking task ownership: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error checking task ownership: %v" , err ) , http . StatusInternalServerError )
2026-01-10 19:17:03 +03:00
return
}
2026-01-28 20:19:53 +03:00
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 )
2026-01-10 19:17:03 +03:00
return
}
2026-01-28 20:19:53 +03:00
// Устанавливаем auto_complete = true
req . AutoComplete = true
2026-01-12 18:58:52 +03:00
2026-01-28 20:19:53 +03:00
// Используем ту же логику что и 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 )
2026-01-10 19:17:03 +03:00
return
}
2026-01-28 20:19:53 +03:00
defer tx . Rollback ( )
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
// Проверяем, существует ли драфт
var draftID int
err = tx . QueryRow ( "SELECT id FROM task_drafts WHERE task_id = $1" , taskID ) . Scan ( & draftID )
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
var progressionValue sql . NullFloat64
if req . ProgressionValue != nil {
progressionValue = sql . NullFloat64 { Float64 : * req . ProgressionValue , Valid : true }
2026-01-10 19:17:03 +03:00
}
2026-01-28 20:19:53 +03:00
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 )
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
if err != nil {
log . Printf ( "Error creating draft: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error creating draft: %v" , err ) , http . StatusInternalServerError )
return
2026-01-10 19:17:03 +03:00
}
2026-01-28 20:19:53 +03:00
} 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
_ , err = tx . Exec ( `
UPDATE task_drafts
SET progression_value = $ 1 , auto_complete = $ 2 , updated_at = NOW ( )
WHERE id = $ 3
` , progressionValue , req . AutoComplete , draftID )
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
if err != nil {
log . Printf ( "Error updating draft: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error updating draft: %v" , err ) , http . StatusInternalServerError )
return
2026-01-10 19:17:03 +03:00
}
2026-01-28 20:19:53 +03:00
// Удаляем все старые записи подзадач
_ , 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
2026-01-10 19:17:03 +03:00
}
}
2026-01-28 20:19:53 +03:00
// Вставляем новые записи подзадач (только checked подзадачи)
2026-01-10 19:17:03 +03:00
if len ( req . ChildrenTaskIDs ) > 0 {
2026-01-28 20:19:53 +03:00
// Проверяем, что все подзадачи принадлежат этой задаче
2026-01-10 19:17:03 +03:00
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 ( `
2026-01-28 20:19:53 +03:00
SELECT id FROM tasks
2026-01-10 19:17:03 +03:00
WHERE parent_task_id = $ 1 AND id IN ( % s ) AND deleted = FALSE
` , strings . Join ( placeholders , "," ) )
2026-01-28 20:19:53 +03:00
validSubtaskRows , err := tx . Query ( query , args ... )
2026-01-10 19:17:03 +03:00
if err != nil {
2026-01-28 20:19:53 +03:00
log . Printf ( "Error validating subtasks: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error validating subtasks: %v" , err ) , http . StatusInternalServerError )
return
}
defer validSubtaskRows . Close ( )
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
validSubtaskIDs := make ( map [ int ] bool )
for validSubtaskRows . Next ( ) {
var id int
if err := validSubtaskRows . Scan ( & id ) ; err == nil {
validSubtaskIDs [ id ] = true
}
}
// Вставляем только валидные подзадачи
for _ , subtaskID := range req . ChildrenTaskIDs {
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 )
2026-01-10 19:17:03 +03:00
if err != nil {
2026-01-28 20:19:53 +03:00
log . Printf ( "Error inserting draft subtask: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error inserting draft subtask: %v" , err ) , http . StatusInternalServerError )
return
2026-01-10 19:17:03 +03:00
}
2026-01-28 20:19:53 +03:00
}
}
}
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
// Коммитим транзакцию
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
}
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
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" ,
} )
}
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
// completeTaskHandler выполняет задачу
func ( a * App ) completeTaskHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
vars := mux . Vars ( r )
taskID , err := strconv . Atoi ( vars [ "id" ] )
if err != nil {
sendErrorWithCORS ( w , "Invalid task ID" , http . StatusBadRequest )
return
}
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
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
}
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
// Используем 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 )
2026-01-10 19:17:03 +03:00
}
2026-01-28 20:19:53 +03:00
return
2026-01-10 19:17:03 +03:00
}
2026-01-28 20:19:53 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"success" : true ,
"message" : "Task completed successfully" ,
} )
}
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
// completeAndDeleteTaskHandler выполняет задачу и затем удаляет её
func ( a * App ) completeAndDeleteTaskHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
2026-01-10 19:17:03 +03:00
}
2026-01-28 20:19:53 +03:00
setCORSHeaders ( w )
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
vars := mux . Vars ( r )
taskID , err := strconv . Atoi ( vars [ "id" ] )
if err != nil {
sendErrorWithCORS ( w , "Invalid task ID" , http . StatusBadRequest )
return
}
2026-01-10 19:17:03 +03:00
2026-01-28 20:19:53 +03:00
// Сначала выполняем задачу используя 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
2026-01-10 19:17:03 +03:00
}
2026-01-28 20:19:53 +03:00
// Используем 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 )
2026-01-12 18:58:52 +03:00
} else {
2026-01-28 20:19:53 +03:00
log . Printf ( "Error executing task: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error executing task: %v" , err ) , http . StatusInternalServerError )
2026-01-12 18:58:52 +03:00
}
2026-01-28 20:19:53 +03:00
return
2026-01-12 18:58:52 +03:00
}
2026-01-10 19:17:03 +03:00
// Помечаем задачу как удаленную
_ , 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 completed and deleted successfully" ,
} )
}
2026-01-06 15:56:52 +03:00
// 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" ,
} )
}
2026-01-02 15:34:01 +03:00
// 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 )
2025-12-31 19:11:28 +03:00
return
}
2026-01-02 15:34:01 +03:00
_ , 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 )
2025-12-31 19:11:28 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-02 15:34:01 +03:00
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"success" : true ,
"message" : "Todoist disconnected" ,
2025-12-31 19:11:28 +03:00
} )
}
2026-01-11 21:12:26 +03:00
// ============================================
// Wishlist handlers
// ============================================
2026-01-12 17:02:33 +03:00
// calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента
2026-01-26 18:45:58 +03:00
// Считает напрямую из таблицы nodes, используя денормализованное поле created_date
2026-01-12 17:02:33 +03:00
func ( a * App ) calculateProjectPointsFromDate (
2026-01-11 21:12:26 +03:00
projectID int ,
2026-01-12 17:02:33 +03:00
startDate sql . NullTime ,
2026-01-11 21:12:26 +03:00
userID int ,
) ( float64 , error ) {
var totalScore float64
var err error
2026-01-12 17:02:33 +03:00
if ! startDate . Valid {
2026-01-12 17:42:51 +03:00
// З а всё время - считаем все nodes этого пользователя для указанного проекта
2026-01-11 21:12:26 +03:00
err = a . DB . QueryRow ( `
2026-01-12 17:42:51 +03:00
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
2026-01-11 21:12:26 +03:00
` , projectID , userID ) . Scan ( & totalScore )
} else {
2026-01-12 17:02:33 +03:00
// С указанной даты до текущего момента
2026-01-26 18:45:58 +03:00
// Считаем все nodes этого пользователя, где дата created_date >= startDate
2026-01-12 17:42:51 +03:00
// Используем DATE() для сравнения только по дате (без времени)
2026-01-26 18:45:58 +03:00
// Теперь используем nodes.created_date напрямую (без JOIN с entries)
2026-01-12 17:02:33 +03:00
err = a . DB . QueryRow ( `
2026-01-12 17:42:51 +03:00
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
2026-01-12 17:02:33 +03:00
AND p . user_id = $ 2
2026-01-26 18:45:58 +03:00
AND DATE ( n . created_date ) >= DATE ( $ 3 )
2026-01-12 17:02:33 +03:00
` , projectID , userID , startDate . Time ) . Scan ( & totalScore )
2026-01-11 21:12:26 +03:00
}
if err != nil {
2026-01-12 17:42:51 +03:00
log . Printf ( "Error calculating project points from date: %v" , err )
2026-01-11 21:12:26 +03:00
return 0 , err
}
return totalScore , nil
}
2026-01-31 18:43:25 +03:00
// getProjectMedian получает медиану проекта из materialized view projects_median_mv
// Если медиана отсутствует, возвращает ошибку
func ( a * App ) getProjectMedian ( projectID int ) ( float64 , error ) {
var median float64
err := a . DB . QueryRow ( `
SELECT median_score
FROM projects_median_mv
WHERE project_id = $ 1
` , projectID ) . Scan ( & median )
if err != nil {
if err == sql . ErrNoRows {
return 0 , fmt . Errorf ( "median not found for project %d" , projectID )
}
return 0 , err
}
return median , nil
}
// calculateProjectUnlockWeeks рассчитывает срок разблокировки проекта в неделях
// projectID - ID проекта
// requiredPoints - необходимое количество баллов
// startDate - дата начала подсчета (может быть nil - за всё время)
// userID - ID пользователя (владельца условия)
// Возвращает количество недель (float64):
// - > 0: условие не выполнено, возвращает количество недель
// - 0: условие уже выполнено (remaining <= 0)
// - 99999: медиана отсутствует или равна 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. Получаем медиану проекта
median , err := a . getProjectMedian ( projectID )
if err != nil || median <= 0 {
// Если медиана отсутствует или равна 0, возвращаем 99999 (нельзя рассчитать)
// Это нормальная ситуация, не логируем
return 99999
}
// 4. Рассчитываем недели
weeks := remaining / median
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 )
}
2026-01-11 21:12:26 +03:00
// 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 ,
2026-01-14 18:30:43 +03:00
wc . user_id AS condition_user_id ,
2026-01-11 21:12:26 +03:00
tc . task_id ,
sc . project_id ,
sc . required_points ,
2026-01-12 17:02:33 +03:00
sc . start_date
2026-01-11 21:12:26 +03:00
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
2026-01-14 18:30:43 +03:00
var conditionUserID sql . NullInt64
2026-01-11 21:12:26 +03:00
var taskID sql . NullInt64
var projectID sql . NullInt64
var requiredPoints sql . NullFloat64
2026-01-12 17:02:33 +03:00
var startDate sql . NullTime
2026-01-11 21:12:26 +03:00
err := rows . Scan (
& wcID , & displayOrder ,
2026-01-14 18:30:43 +03:00
& taskConditionID , & scoreConditionID , & conditionUserID ,
2026-01-12 17:02:33 +03:00
& taskID , & projectID , & requiredPoints , & startDate ,
2026-01-11 21:12:26 +03:00
)
if err != nil {
return false , err
}
2026-01-14 18:30:43 +03:00
// Используем user_id из условия, если он есть, иначе используем текущего пользователя
conditionOwnerID := userID
if conditionUserID . Valid {
conditionOwnerID = int ( conditionUserID . Int64 )
}
2026-01-11 21:12:26 +03:00
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
2026-02-03 13:55:31 +03:00
WHERE id = $ 1 AND user_id = $ 2 AND deleted = FALSE
2026-01-14 18:30:43 +03:00
` , taskID . Int64 , conditionOwnerID ) . Scan ( & completed )
2026-01-11 21:12:26 +03:00
if err == sql . ErrNoRows {
2026-02-03 13:55:31 +03:00
// Задача удалена или не существует - не блокируем желание
conditionMet = true
2026-01-11 21:12:26 +03:00
} else if err != nil {
return false , err
} else {
conditionMet = completed > 0
}
} 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 )
}
2026-01-12 17:02:33 +03:00
totalScore , err := a . calculateProjectPointsFromDate (
2026-01-11 21:12:26 +03:00
int ( projectID . Int64 ) ,
2026-01-12 17:02:33 +03:00
startDate ,
2026-01-14 18:30:43 +03:00
conditionOwnerID ,
2026-01-11 21:12:26 +03:00
)
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
}
2026-01-31 18:43:25 +03:00
// 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
}
// 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
}
}
} )
}
2026-01-11 21:12:26 +03:00
// 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 ,
2026-02-04 15:46:05 +03:00
wi . project_id AS item_project_id ,
wp . name AS item_project_name ,
2026-01-11 21:12:26 +03:00
wc . id AS condition_id ,
wc . display_order ,
wc . task_condition_id ,
wc . score_condition_id ,
2026-01-14 18:30:43 +03:00
wc . user_id AS condition_user_id ,
2026-01-11 21:12:26 +03:00
tc . task_id ,
t . name AS task_name ,
sc . project_id ,
p . name AS project_name ,
sc . required_points ,
2026-01-12 17:02:33 +03:00
sc . start_date
2026-01-11 21:12:26 +03:00
FROM wishlist_items wi
2026-02-04 15:46:05 +03:00
LEFT JOIN projects wp ON wi . project_id = wp . id AND wp . deleted = FALSE
2026-01-11 21:12:26 +03:00
LEFT JOIN wishlist_conditions wc ON wi . id = wc . wishlist_item_id
LEFT JOIN task_conditions tc ON wc . task_condition_id = tc . id
2026-02-03 13:55:31 +03:00
LEFT JOIN tasks t ON tc . task_id = t . id AND t . deleted = FALSE
2026-01-11 21:12:26 +03:00
LEFT JOIN score_conditions sc ON wc . score_condition_id = sc . id
2026-01-19 13:07:17 +03:00
LEFT JOIN projects p ON sc . project_id = p . id AND p . deleted = FALSE
2026-01-11 21:12:26 +03:00
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
2026-02-04 15:46:05 +03:00
var itemProjectID sql . NullInt64
var itemProjectName sql . NullString
2026-01-11 21:12:26 +03:00
var conditionID , displayOrder sql . NullInt64
var taskConditionID , scoreConditionID sql . NullInt64
2026-01-14 18:30:43 +03:00
var conditionUserID sql . NullInt64
2026-01-11 21:12:26 +03:00
var taskID sql . NullInt64
var taskName sql . NullString
var projectID sql . NullInt64
var projectName sql . NullString
var requiredPoints sql . NullFloat64
2026-01-12 17:02:33 +03:00
var startDate sql . NullTime
2026-01-11 21:12:26 +03:00
err := rows . Scan (
& itemID , & name , & price , & imagePath , & link , & completed ,
2026-02-04 15:46:05 +03:00
& itemProjectID , & itemProjectName ,
2026-01-11 21:12:26 +03:00
& conditionID , & displayOrder ,
2026-01-14 18:30:43 +03:00
& taskConditionID , & scoreConditionID , & conditionUserID ,
2026-01-11 21:12:26 +03:00
& taskID , & taskName ,
2026-01-12 17:02:33 +03:00
& projectID , & projectName , & requiredPoints , & startDate ,
2026-01-11 21:12:26 +03:00
)
if err != nil {
return nil , err
}
// Получаем или создаём item
item , exists := itemsMap [ itemID ]
if ! exists {
item = & WishlistItem {
ID : itemID ,
Name : name ,
Completed : completed ,
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
}
2026-02-04 15:46:05 +03:00
if itemProjectID . Valid {
projectIDVal := int ( itemProjectID . Int64 )
item . ProjectID = & projectIDVal
}
if itemProjectName . Valid {
projectNameVal := itemProjectName . String
item . ProjectName = & projectNameVal
}
2026-01-11 21:12:26 +03:00
itemsMap [ itemID ] = item
}
// Добавляем условие, если есть
if conditionID . Valid {
2026-02-03 13:55:31 +03:00
// Определяем владельца условия
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
}
}
2026-01-11 21:12:26 +03:00
condition := UnlockConditionDisplay {
ID : int ( conditionID . Int64 ) ,
DisplayOrder : int ( displayOrder . Int64 ) ,
}
2026-01-19 21:56:57 +03:00
// Заполняем UserID для условия
if conditionUserID . Valid {
conditionOwnerID := int ( conditionUserID . Int64 )
condition . UserID = & conditionOwnerID
} else {
condition . UserID = & userID
}
2026-01-11 21:12:26 +03:00
if taskConditionID . Valid {
condition . Type = "task_completion"
if taskName . Valid {
condition . TaskName = & taskName . String
}
} else if scoreConditionID . Valid {
condition . Type = "project_points"
if projectName . Valid {
condition . ProjectName = & projectName . String
}
2026-01-19 13:07:17 +03:00
if projectID . Valid {
projectIDVal := int ( projectID . Int64 )
condition . ProjectID = & projectIDVal
}
2026-01-11 21:12:26 +03:00
if requiredPoints . Valid {
condition . RequiredPoints = & requiredPoints . Float64
}
2026-01-12 17:02:33 +03:00
if startDate . Valid {
// Форматируем дату в YYYY-MM-DD
dateStr := startDate . Time . Format ( "2006-01-02" )
condition . StartDate = & dateStr
2026-01-11 21:12:26 +03:00
}
}
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
2026-01-31 18:43:25 +03:00
// Сортируем условия в нужном порядке
a . sortUnlockConditions ( item . UnlockConditions , userID )
2026-01-11 21:12:26 +03:00
// Определяем первое заблокированное условие и количество остальных, а также рассчитываем прогресс
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" {
2026-01-14 18:30:43 +03:00
// Находим task_id и user_id для этого условия
2026-01-11 21:12:26 +03:00
var taskID int
2026-01-14 18:30:43 +03:00
var conditionOwnerID int
2026-01-11 21:12:26 +03:00
err = a . DB . QueryRow ( `
2026-01-14 18:30:43 +03:00
SELECT tc . task_id , COALESCE ( wc . user_id , $ 2 )
2026-01-11 21:12:26 +03:00
FROM wishlist_conditions wc
JOIN task_conditions tc ON wc . task_condition_id = tc . id
WHERE wc . id = $ 1
2026-01-14 18:30:43 +03:00
` , condition . ID , userID ) . Scan ( & taskID , & conditionOwnerID )
2026-01-11 21:12:26 +03:00
if err == nil {
var completed int
err = a . DB . QueryRow ( `
2026-02-03 13:55:31 +03:00
SELECT completed FROM tasks WHERE id = $ 1 AND user_id = $ 2 AND deleted = FALSE
2026-01-14 18:30:43 +03:00
` , taskID , conditionOwnerID ) . Scan ( & completed )
2026-02-03 13:55:31 +03:00
if err == sql . ErrNoRows {
// Задача удалена или не существует - не блокируем желание
conditionMet = true
completedBool := true
condition . TaskCompleted = & completedBool
} else if err == nil {
conditionMet = completed > 0
completedBool := conditionMet
condition . TaskCompleted = & completedBool
}
2026-01-11 21:12:26 +03:00
}
} else if condition . Type == "project_points" {
2026-01-14 18:30:43 +03:00
// Находим project_id, required_points и user_id для этого условия
2026-01-11 21:12:26 +03:00
var projectID int
var requiredPoints float64
2026-01-12 17:02:33 +03:00
var startDate sql . NullTime
2026-01-14 18:30:43 +03:00
var conditionOwnerID int
2026-01-11 21:12:26 +03:00
err = a . DB . QueryRow ( `
2026-01-14 18:30:43 +03:00
SELECT sc . project_id , sc . required_points , sc . start_date , COALESCE ( wc . user_id , $ 2 )
2026-01-11 21:12:26 +03:00
FROM wishlist_conditions wc
JOIN score_conditions sc ON wc . score_condition_id = sc . id
WHERE wc . id = $ 1
2026-01-14 18:30:43 +03:00
` , condition . ID , userID ) . Scan ( & projectID , & requiredPoints , & startDate , & conditionOwnerID )
2026-01-11 21:12:26 +03:00
if err == nil {
2026-01-14 18:30:43 +03:00
totalScore , err := a . calculateProjectPointsFromDate ( projectID , startDate , conditionOwnerID )
2026-01-12 17:42:51 +03:00
if err != nil {
// Если ошибка при расчете, устанавливаем 0
zeroScore := 0.0
condition . CurrentPoints = & zeroScore
conditionMet = false
} else {
2026-01-11 21:12:26 +03:00
condition . CurrentPoints = & totalScore
2026-01-12 17:42:51 +03:00
conditionMet = totalScore >= requiredPoints
2026-01-11 21:12:26 +03:00
}
}
}
if ! conditionMet {
lockedCount ++
if firstLocked == nil {
firstLocked = condition
}
}
}
if firstLocked != nil {
item . FirstLockedCondition = firstLocked
item . MoreLockedConditions = lockedCount - 1
2026-01-31 18:43:25 +03:00
item . LockedConditionsCount = lockedCount
2026-01-11 21:12:26 +03:00
}
} else {
// Даже если желание разблокировано, рассчитываем прогресс для всех условий
for i := range item . UnlockConditions {
condition := & item . UnlockConditions [ i ]
if condition . Type == "task_completion" {
var taskID int
2026-01-14 18:30:43 +03:00
var conditionOwnerID int
2026-01-11 21:12:26 +03:00
err := a . DB . QueryRow ( `
2026-01-14 18:30:43 +03:00
SELECT tc . task_id , COALESCE ( wc . user_id , $ 2 )
2026-01-11 21:12:26 +03:00
FROM wishlist_conditions wc
JOIN task_conditions tc ON wc . task_condition_id = tc . id
WHERE wc . id = $ 1
2026-01-14 18:30:43 +03:00
` , condition . ID , userID ) . Scan ( & taskID , & conditionOwnerID )
2026-01-11 21:12:26 +03:00
if err == nil {
var completed int
err = a . DB . QueryRow ( `
2026-02-03 13:55:31 +03:00
SELECT completed FROM tasks WHERE id = $ 1 AND user_id = $ 2 AND deleted = FALSE
2026-01-14 18:30:43 +03:00
` , taskID , conditionOwnerID ) . Scan ( & completed )
2026-02-03 13:55:31 +03:00
if err == sql . ErrNoRows {
// Задача удалена или не существует - не блокируем желание
completedBool := true
condition . TaskCompleted = & completedBool
} else if err == nil {
2026-01-11 21:12:26 +03:00
completedBool := completed > 0
condition . TaskCompleted = & completedBool
}
}
} else if condition . Type == "project_points" {
var projectID int
var requiredPoints float64
2026-01-12 17:02:33 +03:00
var startDate sql . NullTime
2026-01-14 18:30:43 +03:00
var conditionOwnerID int
2026-01-11 21:12:26 +03:00
err := a . DB . QueryRow ( `
2026-01-14 18:30:43 +03:00
SELECT sc . project_id , sc . required_points , sc . start_date , COALESCE ( wc . user_id , $ 2 )
2026-01-11 21:12:26 +03:00
FROM wishlist_conditions wc
JOIN score_conditions sc ON wc . score_condition_id = sc . id
WHERE wc . id = $ 1
2026-01-14 18:30:43 +03:00
` , condition . ID , userID ) . Scan ( & projectID , & requiredPoints , & startDate , & conditionOwnerID )
2026-01-11 21:12:26 +03:00
if err == nil {
2026-01-14 18:30:43 +03:00
totalScore , err := a . calculateProjectPointsFromDate ( projectID , startDate , conditionOwnerID )
2026-01-12 17:42:51 +03:00
if err != nil {
// Если ошибка при расчете, устанавливаем 0
zeroScore := 0.0
condition . CurrentPoints = & zeroScore
} else {
2026-01-11 21:12:26 +03:00
condition . CurrentPoints = & totalScore
}
2026-01-31 18:43:25 +03:00
// Рассчитываем и форматируем срок разблокировки
if condition . ProjectID != nil && condition . RequiredPoints != nil {
weeks := a . calculateProjectUnlockWeeks (
projectID ,
requiredPoints ,
startDate ,
conditionOwnerID ,
)
weeksText := formatWeeksText ( weeks )
condition . WeeksText = & weeksText
}
2026-01-11 21:12:26 +03:00
}
}
}
}
2026-01-30 19:53:13 +03:00
// Загружаем связанную задачу текущего пользователя, если есть
2026-01-13 22:35:01 +03:00
var linkedTaskID , linkedTaskCompleted , linkedTaskUserID sql . NullInt64
2026-01-12 18:58:52 +03:00
var linkedTaskName sql . NullString
var linkedTaskNextShowAt sql . NullTime
linkedTaskErr := a . DB . QueryRow ( `
2026-01-13 22:35:01 +03:00
SELECT t . id , t . name , t . completed , t . next_show_at , t . user_id
2026-01-12 18:58:52 +03:00
FROM tasks t
2026-01-30 19:53:13 +03:00
WHERE t . wishlist_id = $ 1 AND t . user_id = $ 2 AND t . deleted = FALSE
2026-01-12 18:58:52 +03:00
LIMIT 1
2026-01-30 19:53:13 +03:00
` , item . ID , userID ) . Scan ( & linkedTaskID , & linkedTaskName , & linkedTaskCompleted , & linkedTaskNextShowAt , & linkedTaskUserID )
2026-01-26 18:45:58 +03:00
2026-01-12 18:58:52 +03:00
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
}
2026-01-13 22:35:01 +03:00
if linkedTaskUserID . Valid {
userIDVal := int ( linkedTaskUserID . Int64 )
linkedTask . UserID = & userIDVal
}
2026-01-12 18:58:52 +03:00
item . LinkedTask = linkedTask
} else if linkedTaskErr != sql . ErrNoRows {
log . Printf ( "Error loading linked task for wishlist %d: %v" , item . ID , linkedTaskErr )
// Н е возвращаем ошибку, просто не устанавливаем linked_task
}
2026-01-30 19:53:13 +03:00
// Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей)
// Исключаем 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
2026-01-11 21:12:26 +03:00
items = append ( items , * item )
}
return items , nil
}
// saveWishlistConditions сохраняет условия для желания
2026-01-19 13:07:17 +03:00
// userID - автор условий (пользователь, который создает/обновляет условия)
2026-01-11 21:12:26 +03:00
func ( a * App ) saveWishlistConditions (
tx * sql . Tx ,
wishlistItemID int ,
2026-01-19 13:07:17 +03:00
userID int ,
2026-01-11 21:12:26 +03:00
conditions [ ] UnlockConditionRequest ,
) error {
2026-01-19 13:07:17 +03:00
// Получаем все существующие условия с их user_id перед удалением
existingConditions := make ( map [ int ] int ) // map[conditionID]userID
rows , err := tx . Query ( `
SELECT id , user_id
FROM wishlist_conditions
2026-01-11 21:12:26 +03:00
WHERE wishlist_item_id = $ 1
` , wishlistItemID )
if err != nil {
2026-01-19 13:07:17 +03:00
return fmt . Errorf ( "error getting existing conditions: %w" , err )
}
defer rows . Close ( )
2026-01-26 18:45:58 +03:00
2026-01-19 13:07:17 +03:00
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 )
2026-01-11 21:12:26 +03:00
}
if len ( conditions ) == 0 {
return nil
}
// Подготавливаем statement для вставки условий
stmt , err := tx . Prepare ( `
INSERT INTO wishlist_conditions
2026-01-19 13:07:17 +03:00
( wishlist_item_id , user_id , task_condition_id , score_condition_id , display_order )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 )
2026-01-11 21:12:26 +03:00
` )
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" )
}
2026-01-12 17:02:33 +03:00
startDateStr := condition . StartDate
2026-01-26 18:45:58 +03:00
2026-01-11 21:12:26 +03:00
// Получаем или создаём score_condition
var scID int
2026-01-12 17:02:33 +03:00
var startDateVal interface { }
if startDateStr != nil && * startDateStr != "" {
// Парсим дату из строки YYYY-MM-DD
startDateVal = * startDateStr
2026-01-11 21:12:26 +03:00
} else {
// Пустая строка или nil = NULL для "за всё время"
2026-01-12 17:02:33 +03:00
startDateVal = nil
2026-01-11 21:12:26 +03:00
}
err := tx . QueryRow ( `
SELECT id FROM score_conditions
WHERE project_id = $ 1
AND required_points = $ 2
2026-01-12 17:02:33 +03:00
AND ( start_date = $ 3 : : DATE OR ( start_date IS NULL AND $ 3 IS NULL ) )
` , * condition . ProjectID , * condition . RequiredPoints , startDateVal ) . Scan ( & scID )
2026-01-11 21:12:26 +03:00
if err == sql . ErrNoRows {
// Создаём новое условие
err = tx . QueryRow ( `
2026-01-12 17:02:33 +03:00
INSERT INTO score_conditions ( project_id , required_points , start_date )
VALUES ( $ 1 , $ 2 , $ 3 : : DATE )
ON CONFLICT ( project_id , required_points , start_date )
2026-01-11 21:12:26 +03:00
DO UPDATE SET project_id = EXCLUDED . project_id
RETURNING id
2026-01-12 17:02:33 +03:00
` , * condition . ProjectID , * condition . RequiredPoints , startDateVal ) . Scan ( & scID )
2026-01-11 21:12:26 +03:00
if err != nil {
return err
}
} else if err != nil {
return err
}
scoreConditionID = scID
}
2026-01-19 13:07:17 +03:00
// Определяем user_id для условия:
2026-01-19 21:56:57 +03:00
// - Если условие имеет id и это условие существовало - проверяем, принадлежит ли оно текущему пользователю
2026-01-21 18:46:36 +03:00
// - Если условие принадлежит другому пользователю - пропускаем (не сохраняем, так как чужие условия не редактируются)
// - Если условие имеет id, но не существовало (например, было только что добавлено) - это новое условие, используем userID текущего пользователя
// - Если условие без id - это новое условие, используем userID текущего пользователя
2026-01-19 13:07:17 +03:00
conditionUserID := userID
if condition . ID != nil {
if originalUserID , exists := existingConditions [ * condition . ID ] ; exists {
2026-01-21 18:46:36 +03:00
// Если условие принадлежит другому пользователю - пропускаем (не сохраняем, так как чужие условия не редактируются)
2026-01-19 21:56:57 +03:00
if originalUserID != userID {
continue
}
2026-01-21 18:46:36 +03:00
// Условие принадлежит текущему пользователю - обновляем е г о
2026-01-19 13:07:17 +03:00
conditionUserID = originalUserID
2026-01-19 21:56:57 +03:00
} else {
2026-01-21 18:46:36 +03:00
// Условие имеет id, но не существует в базе - это новое условие, используем userID текущего пользователя
conditionUserID = userID
2026-01-19 13:07:17 +03:00
}
}
2026-01-11 21:12:26 +03:00
// Создаём связь
_ , err = stmt . Exec (
wishlistItemID ,
2026-01-19 13:07:17 +03:00
conditionUserID ,
2026-01-11 21:12:26 +03:00
taskConditionID ,
scoreConditionID ,
displayOrder ,
)
if err != nil {
return err
}
}
return nil
}
2026-01-13 20:55:44 +03:00
// getWishlistHandler возвращает список незавершённых желаний и счётчик завершённых
2026-01-11 21:12:26 +03:00
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
}
2026-01-13 20:55:44 +03:00
// Загружаем только незавершённые
items , err := a . getWishlistItemsWithConditions ( userID , false )
2026-01-11 21:12:26 +03:00
if err != nil {
log . Printf ( "Error getting wishlist items: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error getting wishlist items: %v" , err ) , http . StatusInternalServerError )
return
}
2026-01-13 20:55:44 +03:00
// Получаем количество завершённых
2026-01-12 18:58:52 +03:00
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
}
2026-01-11 21:12:26 +03:00
// Группируем и сортируем
unlocked := make ( [ ] WishlistItem , 0 )
locked := make ( [ ] WishlistItem , 0 )
for _ , item := range items {
2026-01-13 20:55:44 +03:00
if item . Unlocked {
2026-01-11 21:12:26 +03:00
unlocked = append ( unlocked , item )
} else {
locked = append ( locked , item )
}
}
2026-01-31 18:43:25 +03:00
// Сортируем разблокированные по цене от меньшего к большему
2026-01-11 21:12:26 +03:00
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
}
2026-01-31 18:43:25 +03:00
if priceI == priceJ {
return unlocked [ i ] . ID < unlocked [ j ] . ID
}
return priceI < priceJ // Сортировка по цене от меньшего к большему (заменяет calculateUnlockedSortValue)
2026-01-11 21:12:26 +03:00
} )
2026-01-31 18:43:25 +03:00
// Разделяем заблокированные на группы
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
}
2026-01-11 21:12:26 +03:00
}
2026-01-31 18:43:25 +03:00
if hasUncompletedTasks {
lockedWithTasks = append ( lockedWithTasks , item )
} else {
lockedWithoutTasks = append ( lockedWithoutTasks , item )
2026-01-11 21:12:26 +03:00
}
2026-01-31 18:43:25 +03:00
}
2026-02-02 20:59:37 +03:00
// Сортируем каждую группу по времени разблокировки (от меньшего срока к большему)
2026-01-31 18:43:25 +03:00
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
}
2026-02-02 20:59:37 +03:00
return valueI < valueJ
2026-01-11 21:12:26 +03:00
} )
2026-01-31 18:43:25 +03:00
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
}
2026-02-02 20:59:37 +03:00
return valueI < valueJ
2026-01-31 18:43:25 +03:00
} )
// Объединяем: сначала без задач, потом с задачами
locked = append ( lockedWithoutTasks , lockedWithTasks ... )
2026-01-13 20:55:44 +03:00
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 )
}
}
// Сортируем по цене (дорогие → дешёвые)
2026-01-11 21:12:26 +03:00
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" )
2026-01-13 20:55:44 +03:00
json . NewEncoder ( w ) . Encode ( completed )
2026-01-11 21:12:26 +03:00
}
// 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 {
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: Unauthorized" )
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: userID=%d" , userID )
2026-01-11 21:12:26 +03:00
var req WishlistRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: Error decoding wishlist request: %v" , err )
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Invalid request body" , http . StatusBadRequest )
return
}
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: decoded request - name='%s', price=%v, link='%s', conditions=%d" ,
req . Name , req . Price , req . Link , len ( req . UnlockConditions ) )
2026-01-26 18:45:58 +03:00
2026-01-21 18:46:36 +03:00
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 )
}
2026-01-11 21:12:26 +03:00
if strings . TrimSpace ( req . Name ) == "" {
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: Name is required" )
2026-01-11 21:12:26 +03:00
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 ( `
2026-02-04 15:46:05 +03:00
INSERT INTO wishlist_items ( user_id , author_id , name , price , link , project_id , completed , deleted )
VALUES ( $ 1 , $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , FALSE , FALSE )
2026-01-11 21:12:26 +03:00
RETURNING id
2026-02-04 15:46:05 +03:00
` , userID , strings . TrimSpace ( req . Name ) , req . Price , req . Link , req . ProjectID ) . Scan ( & wishlistID )
2026-01-11 21:12:26 +03:00
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 {
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: saving %d conditions" , len ( req . UnlockConditions ) )
err = a . saveWishlistConditionsWithUserID ( tx , wishlistID , userID , req . UnlockConditions )
2026-01-11 21:12:26 +03:00
if err != nil {
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: Error saving wishlist conditions: %v" , err )
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , fmt . Sprintf ( "Error saving wishlist conditions: %v" , err ) , http . StatusInternalServerError )
return
}
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: conditions saved successfully" )
} else {
log . Printf ( "createWishlistHandler: no conditions to save" )
2026-01-11 21:12:26 +03:00
}
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: committing transaction" )
2026-01-11 21:12:26 +03:00
if err := tx . Commit ( ) ; err != nil {
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: Error committing transaction: %v" , err )
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , fmt . Sprintf ( "Error committing transaction: %v" , err ) , http . StatusInternalServerError )
return
}
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: transaction committed successfully" )
2026-01-11 21:12:26 +03:00
// Получаем созданное желание с условиями
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 {
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: Created item not found" )
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Created item not found" , http . StatusInternalServerError )
return
}
2026-01-21 18:46:36 +03:00
log . Printf ( "createWishlistHandler: Successfully created wishlist item id=%d, name='%s'" ,
createdItem . ID , createdItem . Name )
2026-01-11 21:12:26 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( createdItem )
}
2026-01-14 18:51:03 +03:00
// 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 )
2026-01-26 18:45:58 +03:00
2026-01-14 18:51:03 +03:00
if err == sql . ErrNoRows {
return false , 0 , sql . NullInt64 { } , err
}
if err != nil {
return false , 0 , sql . NullInt64 { } , err
}
2026-01-26 18:45:58 +03:00
2026-01-14 18:51:03 +03:00
// Проверяем доступ: владелец ИЛИ участник доски
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
}
}
}
}
2026-01-26 18:45:58 +03:00
2026-01-14 18:51:03 +03:00
return hasAccess , itemUserID , boardID , nil
}
2026-01-31 18:43:25 +03:00
// 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 )
}
2026-01-11 21:12:26 +03:00
// 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
}
2026-01-14 18:30:43 +03:00
// Проверяем доступ к желанию
2026-01-14 18:51:03 +03:00
hasAccess , itemUserID , boardID , err := a . checkWishlistAccess ( itemID , userID )
2026-01-14 18:30:43 +03:00
if err == sql . ErrNoRows {
2026-01-14 18:51:03 +03:00
log . Printf ( "Wishlist item not found: id=%d, userID=%d" , itemID , userID )
2026-01-14 18:30:43 +03:00
sendErrorWithCORS ( w , "Wishlist item not found" , http . StatusNotFound )
return
}
2026-01-11 21:12:26 +03:00
if err != nil {
2026-01-14 18:51:03 +03:00
log . Printf ( "Error getting wishlist item (id=%d, userID=%d): %v" , itemID , userID , err )
2026-01-14 18:30:43 +03:00
sendErrorWithCORS ( w , "Error getting wishlist item" , http . StatusInternalServerError )
2026-01-11 21:12:26 +03:00
return
}
2026-01-26 18:45:58 +03:00
2026-01-14 18:51:03 +03:00
log . Printf ( "Wishlist item found: id=%d, itemUserID=%d, boardID=%v, currentUserID=%d" , itemID , itemUserID , boardID , userID )
2026-01-14 18:30:43 +03:00
if ! hasAccess {
2026-01-14 18:51:03 +03:00
log . Printf ( "Access denied for wishlist item: id=%d, itemUserID=%d, boardID=%v, currentUserID=%d" , itemID , itemUserID , boardID , userID )
2026-01-14 18:30:43 +03:00
sendErrorWithCORS ( w , "Access denied" , http . StatusForbidden )
return
}
2026-01-26 18:45:58 +03:00
2026-01-14 18:51:03 +03:00
log . Printf ( "Access granted for wishlist item: id=%d, itemUserID=%d, boardID=%v, currentUserID=%d" , itemID , itemUserID , boardID , userID )
2026-01-14 18:30:43 +03:00
// Сохраняем itemUserID для использования в качестве fallback, если conditionUserID NULL
itemOwnerID := itemUserID
// Загружаем полную информацию о желании
query := `
SELECT
wi . id ,
wi . name ,
wi . price ,
wi . image_path ,
wi . link ,
wi . completed ,
2026-02-04 15:46:05 +03:00
wi . project_id AS item_project_id ,
wp . name AS item_project_name ,
2026-01-14 18:30:43 +03:00
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
2026-02-04 15:46:05 +03:00
LEFT JOIN projects wp ON wi . project_id = wp . id AND wp . deleted = FALSE
2026-01-14 18:30:43 +03:00
LEFT JOIN wishlist_conditions wc ON wi . id = wc . wishlist_item_id
LEFT JOIN task_conditions tc ON wc . task_condition_id = tc . id
2026-02-03 13:55:31 +03:00
LEFT JOIN tasks t ON tc . task_id = t . id AND t . deleted = FALSE
2026-01-14 18:30:43 +03:00
LEFT JOIN score_conditions sc ON wc . score_condition_id = sc . id
2026-01-19 13:07:17 +03:00
LEFT JOIN projects p ON sc . project_id = p . id AND p . deleted = FALSE
2026-01-14 18:30:43 +03:00
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
2026-02-04 15:46:05 +03:00
var itemProjectID sql . NullInt64
var itemProjectName sql . NullString
2026-01-14 18:30:43 +03:00
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 (
2026-02-04 15:46:05 +03:00
& itemID , & name , & price , & imagePath , & link , & completed , & itemProjectID , & itemProjectName ,
2026-01-14 18:30:43 +03:00
& 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
}
2026-02-04 15:46:05 +03:00
if itemProjectID . Valid {
projectIDVal := int ( itemProjectID . Int64 )
item . ProjectID = & projectIDVal
}
if itemProjectName . Valid {
projectNameVal := itemProjectName . String
item . ProjectName = & projectNameVal
}
2026-01-14 18:30:43 +03:00
itemsMap [ itemID ] = item
}
if conditionID . Valid {
2026-02-03 13:55:31 +03:00
// Используем 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
}
}
2026-01-14 18:30:43 +03:00
condition := UnlockConditionDisplay {
ID : int ( conditionID . Int64 ) ,
DisplayOrder : int ( displayOrder . Int64 ) ,
}
if conditionUserID . Valid {
2026-02-03 13:55:31 +03:00
conditionOwnerID := int ( conditionUserID . Int64 )
2026-01-19 21:56:57 +03:00
condition . UserID = & conditionOwnerID
} else {
condition . UserID = & itemOwnerID
2026-01-14 18:30:43 +03:00
}
if taskConditionID . Valid {
condition . Type = "task_completion"
if taskName . Valid {
condition . TaskName = & taskName . String
}
if taskID . Valid {
var taskCompleted int
2026-02-03 13:55:31 +03:00
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 == nil {
isCompleted := taskCompleted > 0
condition . TaskCompleted = & isCompleted
}
2026-01-14 18:30:43 +03:00
}
} else if scoreConditionID . Valid {
condition . Type = "project_points"
if projectName . Valid {
condition . ProjectName = & projectName . String
}
2026-01-19 13:07:17 +03:00
if projectID . Valid {
projectIDVal := int ( projectID . Int64 )
condition . ProjectID = & projectIDVal
points , _ := a . calculateProjectPointsFromDate ( int ( projectID . Int64 ) , startDate , conditionOwnerID )
condition . CurrentPoints = & points
}
2026-01-14 18:30:43 +03:00
if requiredPoints . Valid {
condition . RequiredPoints = & requiredPoints . Float64
}
if startDate . Valid {
dateStr := startDate . Time . Format ( "2006-01-02" )
condition . StartDate = & dateStr
}
2026-01-31 18:43:25 +03:00
// Рассчитываем и форматируем срок разблокировки
if condition . ProjectID != nil && condition . RequiredPoints != nil {
weeks := a . calculateProjectUnlockWeeks (
* condition . ProjectID ,
* condition . RequiredPoints ,
startDate ,
conditionOwnerID ,
)
weeksText := formatWeeksText ( weeks )
condition . WeeksText = & weeksText
}
2026-01-14 18:30:43 +03:00
}
item . UnlockConditions = append ( item . UnlockConditions , condition )
}
}
// Получаем желание из map
2026-01-11 21:12:26 +03:00
var item * WishlistItem
2026-01-14 18:30:43 +03:00
for _ , it := range itemsMap {
if it . ID == itemID {
item = it
2026-01-11 21:12:26 +03:00
break
}
}
if item == nil {
sendErrorWithCORS ( w , "Wishlist item not found" , http . StatusNotFound )
return
}
2026-01-14 18:30:43 +03:00
// Проверяем разблокировку
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
}
2026-01-31 18:43:25 +03:00
// Сортируем условия в нужном порядке
a . sortUnlockConditions ( item . UnlockConditions , userID )
2026-01-30 19:53:13 +03:00
// Загружаем связанную задачу текущего пользователя, если есть
2026-01-13 22:35:01 +03:00
var linkedTaskID , linkedTaskCompleted , linkedTaskUserID sql . NullInt64
2026-01-12 18:58:52 +03:00
var linkedTaskName sql . NullString
var linkedTaskNextShowAt sql . NullTime
err = a . DB . QueryRow ( `
2026-01-13 22:35:01 +03:00
SELECT t . id , t . name , t . completed , t . next_show_at , t . user_id
2026-01-12 18:58:52 +03:00
FROM tasks t
2026-01-30 19:53:13 +03:00
WHERE t . wishlist_id = $ 1 AND t . user_id = $ 2 AND t . deleted = FALSE
2026-01-12 18:58:52 +03:00
LIMIT 1
2026-01-30 19:53:13 +03:00
` , itemID , userID ) . Scan ( & linkedTaskID , & linkedTaskName , & linkedTaskCompleted , & linkedTaskNextShowAt , & linkedTaskUserID )
2026-01-26 18:45:58 +03:00
2026-01-12 18:58:52 +03:00
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
}
2026-01-13 22:35:01 +03:00
if linkedTaskUserID . Valid {
userIDVal := int ( linkedTaskUserID . Int64 )
linkedTask . UserID = & userIDVal
}
2026-01-12 18:58:52 +03:00
item . LinkedTask = linkedTask
} else if err != sql . ErrNoRows {
log . Printf ( "Error loading linked task for wishlist %d: %v" , itemID , err )
// Н е возвращаем ошибку, просто не устанавливаем linked_task
}
2026-01-30 19:53:13 +03:00
// Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей)
// Исключаем 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
2026-01-11 21:12:26 +03:00
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 )
2026-01-14 18:51:03 +03:00
log . Printf ( "updateWishlistHandler called: method=%s, path=%s" , r . Method , r . URL . Path )
2026-01-11 21:12:26 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
2026-01-14 18:51:03 +03:00
log . Printf ( "updateWishlistHandler: Unauthorized" )
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
vars := mux . Vars ( r )
itemID , err := strconv . Atoi ( vars [ "id" ] )
if err != nil {
2026-01-14 18:51:03 +03:00
log . Printf ( "updateWishlistHandler: Invalid wishlist ID: %v" , err )
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Invalid wishlist ID" , http . StatusBadRequest )
return
}
2026-01-26 18:45:58 +03:00
2026-01-14 18:51:03 +03:00
log . Printf ( "updateWishlistHandler: itemID=%d, userID=%d" , itemID , userID )
2026-01-11 21:12:26 +03:00
2026-01-14 18:51:03 +03:00
// Проверяем доступ к желанию
hasAccess , _ , _ , err := a . checkWishlistAccess ( itemID , userID )
if err == sql . ErrNoRows {
log . Printf ( "updateWishlistHandler: Wishlist item not found: id=%d, userID=%d" , itemID , userID )
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Wishlist item not found" , http . StatusNotFound )
return
}
if err != nil {
2026-01-14 18:51:03 +03:00
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 )
2026-01-11 21:12:26 +03:00
return
}
2026-01-26 18:45:58 +03:00
2026-01-14 18:51:03 +03:00
log . Printf ( "updateWishlistHandler: Access granted: id=%d, userID=%d" , itemID , userID )
2026-01-11 21:12:26 +03:00
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 ( )
2026-01-14 18:51:03 +03:00
// Обновляем желание (не проверяем user_id в WHERE, так как доступ уже проверен выше)
2026-01-11 21:12:26 +03:00
_ , err = tx . Exec ( `
UPDATE wishlist_items
2026-02-04 15:46:05 +03:00
SET name = $ 1 , price = $ 2 , link = $ 3 , project_id = $ 4 , updated_at = NOW ( )
WHERE id = $ 5
` , strings . TrimSpace ( req . Name ) , req . Price , req . Link , req . ProjectID , itemID )
2026-01-11 21:12:26 +03:00
if err != nil {
log . Printf ( "Error updating wishlist item: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error updating wishlist item: %v" , err ) , http . StatusInternalServerError )
return
}
// Сохраняем условия
2026-01-19 13:07:17 +03:00
err = a . saveWishlistConditions ( tx , itemID , userID , req . UnlockConditions )
2026-01-11 21:12:26 +03:00
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
}
2026-01-14 18:51:03 +03:00
// Получаем обновлённое желание через getWishlistItemHandler логику
// Используем тот же запрос, что и в getWishlistItemHandler
query := `
SELECT
wi . id ,
wi . name ,
wi . price ,
wi . image_path ,
wi . link ,
wi . completed ,
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
2026-02-03 13:55:31 +03:00
LEFT JOIN tasks t ON tc . task_id = t . id AND t . deleted = FALSE
2026-01-14 18:51:03 +03:00
LEFT JOIN score_conditions sc ON wc . score_condition_id = sc . id
2026-01-19 13:07:17 +03:00
LEFT JOIN projects p ON sc . project_id = p . id AND p . deleted = FALSE
2026-01-14 18:51:03 +03:00
WHERE wi . id = $ 1
AND wi . deleted = FALSE
ORDER BY wc . display_order , wc . id
`
rows , err := a . DB . Query ( query , itemID )
2026-01-11 21:12:26 +03:00
if err != nil {
2026-01-14 18:51:03 +03:00
log . Printf ( "Error querying updated wishlist item: %v" , err )
sendErrorWithCORS ( w , "Error getting updated wishlist item" , http . StatusInternalServerError )
2026-01-11 21:12:26 +03:00
return
}
2026-01-14 18:51:03 +03:00
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 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 ,
& 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
}
2026-01-26 18:45:58 +03:00
2026-01-14 18:51:03 +03:00
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
}
itemsMap [ itemID ] = item
}
if conditionID . Valid {
2026-02-03 13:55:31 +03:00
// Определяем владельца условия
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
}
}
2026-01-14 18:51:03 +03:00
condition := UnlockConditionDisplay {
ID : int ( conditionID . Int64 ) ,
DisplayOrder : int ( displayOrder . Int64 ) ,
}
if conditionUserID . Valid {
2026-02-03 13:55:31 +03:00
conditionOwnerID := int ( conditionUserID . Int64 )
2026-01-19 21:56:57 +03:00
condition . UserID = & conditionOwnerID
} else {
condition . UserID = & itemOwnerID
2026-01-14 18:51:03 +03:00
}
if taskConditionID . Valid {
condition . Type = "task_completion"
if taskName . Valid {
condition . TaskName = & taskName . String
}
if taskID . Valid {
var taskCompleted int
2026-02-03 13:55:31 +03:00
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 == nil {
isCompleted := taskCompleted > 0
condition . TaskCompleted = & isCompleted
}
2026-01-14 18:51:03 +03:00
}
} else if scoreConditionID . Valid {
condition . Type = "project_points"
if projectName . Valid {
condition . ProjectName = & projectName . String
}
2026-01-19 13:07:17 +03:00
if projectID . Valid {
projectIDVal := int ( projectID . Int64 )
condition . ProjectID = & projectIDVal
points , _ := a . calculateProjectPointsFromDate ( int ( projectID . Int64 ) , startDate , conditionOwnerID )
condition . CurrentPoints = & points
}
2026-01-14 18:51:03 +03:00
if requiredPoints . Valid {
condition . RequiredPoints = & requiredPoints . Float64
}
if startDate . Valid {
dateStr := startDate . Time . Format ( "2006-01-02" )
condition . StartDate = & dateStr
}
2026-01-31 18:43:25 +03:00
// Рассчитываем и форматируем срок разблокировки
if condition . ProjectID != nil && condition . RequiredPoints != nil {
weeks := a . calculateProjectUnlockWeeks (
* condition . ProjectID ,
* condition . RequiredPoints ,
startDate ,
conditionOwnerID ,
)
weeksText := formatWeeksText ( weeks )
condition . WeeksText = & weeksText
}
2026-01-14 18:51:03 +03:00
}
item . UnlockConditions = append ( item . UnlockConditions , condition )
}
}
2026-01-11 21:12:26 +03:00
var updatedItem * WishlistItem
2026-01-14 18:51:03 +03:00
for _ , it := range itemsMap {
if it . ID == itemID {
updatedItem = it
2026-01-11 21:12:26 +03:00
break
}
}
if updatedItem == nil {
2026-01-14 18:51:03 +03:00
log . Printf ( "Updated item not found: id=%d" , itemID )
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Updated item not found" , http . StatusInternalServerError )
return
}
2026-01-14 18:51:03 +03:00
// Проверяем разблокировку
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
}
2026-01-31 18:43:25 +03:00
// Сортируем условия в нужном порядке
a . sortUnlockConditions ( updatedItem . UnlockConditions , userID )
2026-01-11 21:12:26 +03:00
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
}
2026-01-14 18:51:03 +03:00
// Проверяем доступ к желанию
hasAccess , _ , _ , err := a . checkWishlistAccess ( itemID , userID )
if err == sql . ErrNoRows {
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Wishlist item not found" , http . StatusNotFound )
return
}
if err != nil {
2026-01-14 18:51:03 +03:00
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 )
2026-01-11 21:12:26 +03:00
return
}
_ , err = a . DB . Exec ( `
UPDATE wishlist_items
SET deleted = TRUE , updated_at = NOW ( )
2026-01-14 18:51:03 +03:00
WHERE id = $ 1
` , itemID )
2026-01-11 21:12:26 +03:00
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
}
2026-01-14 18:51:03 +03:00
// Проверяем доступ к желанию
hasAccess , _ , _ , err := a . checkWishlistAccess ( wishlistID , userID )
if err == sql . ErrNoRows {
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Wishlist item not found" , http . StatusNotFound )
return
}
if err != nil {
2026-01-14 18:51:03 +03:00
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 )
2026-01-11 21:12:26 +03:00
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
}
2026-01-12 17:42:51 +03:00
// Генерируем уникальное имя файла
randomBytes := make ( [ ] byte , 8 )
rand . Read ( randomBytes )
filename := fmt . Sprintf ( "%d_%x.jpg" , wishlistID , randomBytes )
2026-01-11 21:12:26 +03:00
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
}
2026-01-12 17:42:51 +03:00
// Обновляем путь в БД (уникальное имя файла уже обеспечивает с б р о с кэша)
2026-01-11 21:12:26 +03:00
imagePath := fmt . Sprintf ( "/uploads/wishlist/%d/%s" , userID , filename )
_ , err = a . DB . Exec ( `
UPDATE wishlist_items
SET image_path = $ 1 , updated_at = NOW ( )
2026-01-14 18:51:03 +03:00
WHERE id = $ 2
` , imagePath , wishlistID )
2026-01-11 21:12:26 +03:00
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 ,
} )
}
// 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
}
2026-01-14 18:51:03 +03:00
// Проверяем доступ к желанию
hasAccess , _ , _ , err := a . checkWishlistAccess ( itemID , userID )
if err == sql . ErrNoRows {
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Wishlist item not found" , http . StatusNotFound )
return
}
if err != nil {
2026-01-14 18:51:03 +03:00
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 )
2026-01-11 21:12:26 +03:00
return
}
_ , err = a . DB . Exec ( `
UPDATE wishlist_items
SET completed = TRUE , updated_at = NOW ( )
2026-01-14 18:51:03 +03:00
WHERE id = $ 1
` , itemID )
2026-01-11 21:12:26 +03:00
if err != nil {
log . Printf ( "Error completing wishlist item: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error completing wishlist item: %v" , err ) , http . StatusInternalServerError )
return
}
2026-01-30 19:53:13 +03:00
// Находим задачу пользователя для этого желания, чтобы исключить её из обработки
// (так же, как при закрытии через задачу)
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
}
2026-01-13 22:35:01 +03:00
// Обрабатываем политику награждения для всех задач, связанных с этим желанием
2026-01-30 19:53:13 +03:00
// Исключаем задачу пользователя, который закрыл желание (если она есть)
a . processWishlistRewardPolicy ( itemID , userTaskID )
2026-01-13 22:35:01 +03:00
2026-01-11 21:12:26 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"success" : true ,
"message" : "Wishlist item completed successfully" ,
} )
}
2026-01-13 22:35:01 +03:00
// processWishlistRewardPolicy обрабатывает политику награждения для всех задач, связанных с желанием
2026-01-30 19:53:13 +03:00
// 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 )
}
2026-01-13 22:35:01 +03:00
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" {
2026-01-30 19:53:13 +03:00
// Личная политика: при закрытии задачи-желания другим пользователем, личная задача удаляется
_ , 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 )
2026-01-13 22:35:01 +03:00
} else {
2026-01-30 19:53:13 +03:00
log . Printf ( "Task %d deleted because wishlist item %d was completed by another user (personal policy)" , taskID , wishlistItemID )
2026-01-13 22:35:01 +03:00
}
} else if policy == "general" {
2026-01-30 19:53:13 +03:00
// Общая политика: при закрытии задачи-желания другим пользователем, общая задача закрывается
2026-01-13 22:35:01 +03:00
_ , 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 )
}
}
}
}
2026-01-11 21:12:26 +03:00
// 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
}
2026-01-14 18:51:03 +03:00
// Проверяем доступ к желанию
hasAccess , _ , _ , err := a . checkWishlistAccess ( itemID , userID )
if err == sql . ErrNoRows {
2026-01-11 21:12:26 +03:00
sendErrorWithCORS ( w , "Wishlist item not found" , http . StatusNotFound )
return
}
if err != nil {
2026-01-14 18:51:03 +03:00
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 )
2026-01-11 21:12:26 +03:00
return
}
_ , err = a . DB . Exec ( `
UPDATE wishlist_items
SET completed = FALSE , updated_at = NOW ( )
2026-01-14 18:51:03 +03:00
WHERE id = $ 1
` , itemID )
2026-01-11 21:12:26 +03:00
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" ,
} )
}
2026-01-13 20:55:44 +03:00
// 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
2026-01-19 13:07:17 +03:00
var boardID sql . NullInt64
var authorID sql . NullInt64
2026-01-13 20:55:44 +03:00
err = a . DB . QueryRow ( `
2026-01-19 13:07:17 +03:00
SELECT user_id , name , price , link , image_path , board_id , author_id
2026-01-13 20:55:44 +03:00
FROM wishlist_items
WHERE id = $ 1 AND deleted = FALSE
2026-01-19 13:07:17 +03:00
` , itemID ) . Scan ( & ownerID , & name , & price , & link , & imagePath , & boardID , & authorID )
2026-01-26 18:45:58 +03:00
2026-01-13 20:55:44 +03:00
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
}
2026-01-26 18:45:58 +03:00
2026-01-19 13:07:17 +03:00
// Определяем значения для board_id и author_id
var boardIDVal , authorIDVal interface { }
if boardID . Valid {
boardIDVal = int ( boardID . Int64 )
}
if authorID . Valid {
authorIDVal = int ( authorID . Int64 )
} else {
// Если author_id не был установлен, используем текущего пользователя
authorIDVal = userID
}
2026-01-26 18:45:58 +03:00
2026-01-13 20:55:44 +03:00
err = tx . QueryRow ( `
2026-01-19 13:07:17 +03:00
INSERT INTO wishlist_items ( user_id , board_id , author_id , name , price , link , completed , deleted )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , $ 6 , FALSE , FALSE )
2026-01-13 20:55:44 +03:00
RETURNING id
2026-01-19 13:07:17 +03:00
` , ownerID , boardIDVal , authorIDVal , name + " (копия)" , priceVal , linkVal ) . Scan ( & newWishlistID )
2026-01-13 20:55:44 +03:00
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 {
2026-01-19 13:07:17 +03:00
err = a . saveWishlistConditions ( tx , newWishlistID , userID , conditions )
2026-01-13 20:55:44 +03:00
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" )
2026-01-26 18:45:58 +03:00
2026-01-13 20:55:44 +03:00
// Очищаем путь от /uploads/ в начале и query параметров
cleanPath := imagePath . String
cleanPath = strings . TrimPrefix ( cleanPath , "/uploads/" )
if idx := strings . Index ( cleanPath , "?" ) ; idx != - 1 {
cleanPath = cleanPath [ : idx ]
}
2026-01-26 18:45:58 +03:00
2026-01-13 20:55:44 +03:00
originalPath := filepath . Join ( uploadsDir , cleanPath )
2026-01-26 18:45:58 +03:00
2026-01-13 20:55:44 +03:00
log . Printf ( "Copying image: imagePath=%s, cleanPath=%s, originalPath=%s" , imagePath . String , cleanPath , originalPath )
2026-01-26 18:45:58 +03:00
2026-01-13 20:55:44 +03:00
// Проверяем, существует ли файл
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 )
}
2026-01-26 18:45:58 +03:00
2026-01-13 20:55:44 +03:00
// Генерируем уникальное имя файла
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 )
2026-01-26 18:45:58 +03:00
2026-01-13 20:55:44 +03:00
log . Printf ( "New image path: %s" , newImagePath )
2026-01-26 18:45:58 +03:00
2026-01-13 20:55:44 +03:00
// Копируем файл
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
}
// Получаем созданное желание с условиями
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 )
}
2026-01-13 22:35:01 +03:00
// ============================================
// 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 )
2026-01-26 18:45:58 +03:00
2026-01-13 22:35:01 +03:00
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 ) != "" {
2026-01-26 18:45:58 +03:00
_ , err = a . DB . Exec ( ` UPDATE wishlist_boards SET name = $1, updated_at = NOW() WHERE id = $2 ` ,
2026-01-13 22:35:01 +03:00
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 ( & currentToken )
2026-01-26 18:45:58 +03:00
2026-01-13 22:35:01 +03:00
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
2026-01-26 18:45:58 +03:00
a . DB . QueryRow ( ` SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2) ` ,
2026-01-13 22:35:01 +03:00
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
}
2026-01-26 18:45:58 +03:00
2026-01-13 22:35:01 +03:00
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
}
2026-01-26 18:45:58 +03:00
2026-01-13 22:35:01 +03:00
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 )
}
}
2026-01-31 18:43:25 +03:00
// Сортируем разблокированные по цене от меньшего к большему
2026-01-29 15:54:04 +03:00
sort . Slice ( unlocked , func ( i , j int ) bool {
2026-01-31 18:43:25 +03:00
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 {
2026-01-29 15:54:04 +03:00
return unlocked [ i ] . ID < unlocked [ j ] . ID
}
2026-01-31 18:43:25 +03:00
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 )
}
}
2026-02-02 20:59:37 +03:00
// Сортируем каждую группу по времени разблокировки (от меньшего срока к большему)
2026-01-31 18:43:25 +03:00
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
}
2026-02-02 20:59:37 +03:00
return valueI < valueJ
2026-01-29 15:54:04 +03:00
} )
2026-01-31 18:43:25 +03:00
sort . Slice ( lockedWithTasks , func ( i , j int ) bool {
valueI := a . calculateLockedSortValue ( lockedWithTasks [ i ] , userID )
valueJ := a . calculateLockedSortValue ( lockedWithTasks [ j ] , userID )
2026-01-29 15:54:04 +03:00
if valueI == valueJ {
2026-01-31 18:43:25 +03:00
return lockedWithTasks [ i ] . ID < lockedWithTasks [ j ] . ID
2026-01-29 15:54:04 +03:00
}
2026-02-02 20:59:37 +03:00
return valueI < valueJ
2026-01-29 15:54:04 +03:00
} )
2026-01-31 18:43:25 +03:00
// Объединяем: сначала без задач, потом с задачами
locked = append ( lockedWithoutTasks , lockedWithTasks ... )
2026-01-13 22:35:01 +03:00
// Считаем завершённые
var completedCount int
2026-01-26 18:45:58 +03:00
a . DB . QueryRow ( ` SELECT COUNT(*) FROM wishlist_items WHERE board_id = $1 AND completed = TRUE AND deleted = FALSE ` ,
2026-01-13 22:35:01 +03:00
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
}
2026-01-26 18:45:58 +03:00
2026-01-13 22:35:01 +03:00
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
}
2026-01-26 18:45:58 +03:00
2026-01-13 22:35:01 +03:00
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 ,
2026-02-04 15:46:05 +03:00
wi . project_id AS item_project_id ,
wp . name AS item_project_name ,
2026-01-13 22:35:01 +03:00
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
2026-02-04 15:46:05 +03:00
LEFT JOIN projects wp ON wi . project_id = wp . id AND wp . deleted = FALSE
2026-01-13 22:35:01 +03:00
LEFT JOIN wishlist_conditions wc ON wi . id = wc . wishlist_item_id
LEFT JOIN task_conditions tc ON wc . task_condition_id = tc . id
2026-02-03 13:55:31 +03:00
LEFT JOIN tasks t ON tc . task_id = t . id AND t . deleted = FALSE
2026-01-13 22:35:01 +03:00
LEFT JOIN score_conditions sc ON wc . score_condition_id = sc . id
2026-01-19 13:07:17 +03:00
LEFT JOIN projects p ON sc . project_id = p . id AND p . deleted = FALSE
2026-01-13 22:35:01 +03:00
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
2026-02-04 15:46:05 +03:00
var itemProjectID sql . NullInt64
var itemProjectName sql . NullString
2026-01-13 22:35:01 +03:00
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 (
2026-02-04 15:46:05 +03:00
& itemID , & name , & price , & imagePath , & link , & completed , & itemProjectID , & itemProjectName ,
2026-01-13 22:35:01 +03:00
& 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 ,
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
}
2026-02-04 15:46:05 +03:00
// Для завершённых желаний не устанавливаем project_id и project_name
// Они отображаются отдельно без группировки по проектам
2026-01-13 22:35:01 +03:00
itemsMap [ itemID ] = item
}
if conditionID . Valid {
2026-02-03 13:55:31 +03:00
// Определяем владельца условия
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
}
}
2026-01-13 22:35:01 +03:00
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 )
}
2026-01-29 15:54:04 +03:00
// 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
}
}
2026-01-19 22:25:28 +03:00
}
2026-01-29 15:54:04 +03:00
return totalRequired
}
2026-01-19 22:25:28 +03:00
2026-01-29 15:54:04 +03:00
// calculateLockedSortValue считает сумму оставшихся баллов для разблокировки
// Задача считается как 1 балл (если не выполнена), project_points как remaining баллы
2026-01-31 18:43:25 +03:00
func ( a * App ) calculateLockedSortValue ( item WishlistItem , userID int ) float64 {
// Если нет условий, возвращаем большое значение (отсутствие условий = все выполнены)
if len ( item . UnlockConditions ) == 0 {
return 999999.0
}
maxWeeks := 0.0
hasProjectConditions := false
2026-02-02 20:59:37 +03:00
allCompleted := true
2026-01-31 18:43:25 +03:00
2026-01-19 22:25:28 +03:00
for _ , condition := range item . UnlockConditions {
2026-01-31 18:43:25 +03:00
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 означает медиана отсутствует (нельзя рассчитать) или ошибка расчета
2026-02-02 20:59:37 +03:00
if weeks == 0 {
// Условие выполнено - считаем как 0 недель
// Н е обновляем maxWeeks, так как 0 < любого положительного значения
} else if weeks > 0 && weeks < 99999 {
// Условие не выполнено - учитываем в maxWeeks
allCompleted = false
2026-01-31 18:43:25 +03:00
if weeks > maxWeeks {
maxWeeks = weeks
}
2026-02-02 20:59:37 +03:00
} else {
// weeks == 99999 - нельзя рассчитать, считаем как невыполненное
allCompleted = false
2026-01-31 18:43:25 +03:00
}
2026-01-19 22:25:28 +03:00
}
}
}
}
2026-01-31 18:43:25 +03:00
2026-02-02 20:59:37 +03:00
// Если были условия по проектам и все выполнены, возвращаем 0 (закрытые испытания = 0 недель)
if hasProjectConditions && allCompleted {
return 0.0
2026-01-31 18:43:25 +03:00
}
// Если не было условий по проектам (только задачи или нет условий)
if ! hasProjectConditions {
2026-01-29 15:54:04 +03:00
return 999999.0
2026-01-19 22:25:28 +03:00
}
2026-01-31 18:43:25 +03:00
return maxWeeks
2026-01-19 22:25:28 +03:00
}
2026-01-29 15:54:04 +03:00
// getWishlistItemsByBoard загружает желания конкретной доски
2026-01-13 22:35:01 +03:00
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 ,
2026-02-04 15:46:05 +03:00
wi . project_id AS item_project_id ,
wp . name AS item_project_name ,
2026-01-19 13:07:17 +03:00
COALESCE ( wi . author_id , wi . user_id ) AS item_owner_id ,
2026-01-13 22:35:01 +03:00
wc . id AS condition_id ,
wc . display_order ,
wc . task_condition_id ,
wc . score_condition_id ,
2026-01-14 18:30:43 +03:00
wc . user_id AS condition_user_id ,
2026-01-13 22:35:01 +03:00
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
2026-02-04 15:46:05 +03:00
LEFT JOIN projects wp ON wi . project_id = wp . id AND wp . deleted = FALSE
2026-01-13 22:35:01 +03:00
LEFT JOIN wishlist_conditions wc ON wi . id = wc . wishlist_item_id
LEFT JOIN task_conditions tc ON wc . task_condition_id = tc . id
2026-02-03 13:55:31 +03:00
LEFT JOIN tasks t ON tc . task_id = t . id AND t . deleted = FALSE
2026-01-13 22:35:01 +03:00
LEFT JOIN score_conditions sc ON wc . score_condition_id = sc . id
2026-01-19 13:07:17 +03:00
LEFT JOIN projects p ON sc . project_id = p . id AND p . deleted = FALSE
2026-01-13 22:35:01 +03:00
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
2026-02-04 15:46:05 +03:00
var itemProjectID sql . NullInt64
var itemProjectName sql . NullString
2026-01-19 13:07:17 +03:00
var itemOwnerID sql . NullInt64
2026-01-13 22:35:01 +03:00
var conditionID sql . NullInt64
var displayOrder sql . NullInt64
var taskConditionID sql . NullInt64
var scoreConditionID sql . NullInt64
2026-01-14 18:30:43 +03:00
var conditionUserID sql . NullInt64
2026-01-13 22:35:01 +03:00
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 (
2026-02-04 15:46:05 +03:00
& itemID , & name , & price , & imagePath , & link , & completed , & itemProjectID , & itemProjectName , & itemOwnerID ,
2026-01-14 18:30:43 +03:00
& conditionID , & displayOrder , & taskConditionID , & scoreConditionID , & conditionUserID ,
2026-01-13 22:35:01 +03:00
& 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
}
2026-02-04 15:46:05 +03:00
if itemProjectID . Valid {
projectIDVal := int ( itemProjectID . Int64 )
item . ProjectID = & projectIDVal
}
if itemProjectName . Valid {
projectNameVal := itemProjectName . String
item . ProjectName = & projectNameVal
}
2026-01-13 22:35:01 +03:00
itemsMap [ itemID ] = item
}
if conditionID . Valid {
2026-01-19 13:07:17 +03:00
// Используем 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 )
2026-01-14 18:30:43 +03:00
if conditionUserID . Valid {
conditionOwnerID = int ( conditionUserID . Int64 )
2026-02-03 13:55:31 +03:00
}
// Если это условие по задаче, проверяем существует ли задача
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
2026-01-19 21:56:57 +03:00
} else {
itemOwnerIDVal := int ( itemOwnerID . Int64 )
condition . UserID = & itemOwnerIDVal
2026-01-14 18:30:43 +03:00
}
2026-01-13 22:35:01 +03:00
if taskConditionID . Valid {
condition . Type = "task_completion"
if taskName . Valid {
condition . TaskName = & taskName . String
}
2026-01-14 18:30:43 +03:00
// Проверяем выполнена ли задача для владельца условия
2026-01-13 22:35:01 +03:00
if taskID . Valid {
var taskCompleted int
2026-02-03 13:55:31 +03:00
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 == nil {
isCompleted := taskCompleted > 0
condition . TaskCompleted = & isCompleted
}
2026-01-13 22:35:01 +03:00
}
} else if scoreConditionID . Valid {
condition . Type = "project_points"
if projectName . Valid {
condition . ProjectName = & projectName . String
}
2026-01-19 13:07:17 +03:00
if projectID . Valid {
projectIDVal := int ( projectID . Int64 )
condition . ProjectID = & projectIDVal
// Считаем текущие баллы для владельца условия
points , _ := a . calculateProjectPointsFromDate ( int ( projectID . Int64 ) , startDate , conditionOwnerID )
condition . CurrentPoints = & points
}
2026-01-13 22:35:01 +03:00
if requiredPoints . Valid {
condition . RequiredPoints = & requiredPoints . Float64
}
if startDate . Valid {
dateStr := startDate . Time . Format ( "2006-01-02" )
condition . StartDate = & dateStr
}
2026-01-31 18:43:25 +03:00
// Рассчитываем и форматируем срок разблокировки
if condition . ProjectID != nil && condition . RequiredPoints != nil {
weeks := a . calculateProjectUnlockWeeks (
* condition . ProjectID ,
* condition . RequiredPoints ,
startDate ,
conditionOwnerID ,
)
weeksText := formatWeeksText ( weeks )
condition . WeeksText = & weeksText
}
2026-01-13 22:35:01 +03:00
}
item . UnlockConditions = append ( item . UnlockConditions , condition )
}
}
// Преобразуем map в slice и определяем unlocked
items := make ( [ ] WishlistItem , 0 , len ( itemsMap ) )
for _ , item := range itemsMap {
2026-01-31 18:43:25 +03:00
// Сортируем условия в нужном порядке
a . sortUnlockConditions ( item . UnlockConditions , userID )
2026-01-13 22:35:01 +03:00
// Проверяем все условия
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
}
}
}
}
2026-01-30 19:53:13 +03:00
2026-01-31 18:43:25 +03:00
// Определяем первое заблокированное условие и количество остальных
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
}
}
2026-01-30 19:53:13 +03:00
// Загружаем связанную задачу текущего пользователя, если есть
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
2026-01-13 22:35:01 +03:00
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 {
2026-01-21 18:46:36 +03:00
log . Printf ( "createBoardItemHandler: Error parsing boardId from URL: %v, vars['boardId']='%s'" , err , vars [ "boardId" ] )
2026-01-13 22:35:01 +03:00
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
}
2026-01-26 18:45:58 +03:00
2026-01-13 22:35:01 +03:00
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
}
2026-01-26 18:45:58 +03:00
2026-01-13 22:35:01 +03:00
if ! hasAccess {
sendErrorWithCORS ( w , "Access denied" , http . StatusForbidden )
return
}
var req WishlistRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-21 18:46:36 +03:00
log . Printf ( "createBoardItemHandler: Error decoding wishlist request: %v" , err )
2026-01-13 22:35:01 +03:00
sendErrorWithCORS ( w , "Invalid request body" , http . StatusBadRequest )
return
}
2026-01-21 18:46:36 +03:00
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 { }
}
2026-01-13 22:35:01 +03:00
if strings . TrimSpace ( req . Name ) == "" {
2026-01-21 18:46:36 +03:00
log . Printf ( "createBoardItemHandler: Name is required" )
2026-01-13 22:35:01 +03:00
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 ( `
2026-02-04 15:46:05 +03:00
INSERT INTO wishlist_items ( user_id , board_id , author_id , name , price , link , project_id , completed , deleted )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , $ 6 , $ 7 , FALSE , FALSE )
2026-01-13 22:35:01 +03:00
RETURNING id
2026-02-04 15:46:05 +03:00
` , ownerID , boardID , userID , strings . TrimSpace ( req . Name ) , req . Price , req . Link , req . ProjectID ) . Scan ( & itemID )
2026-01-13 22:35:01 +03:00
if err != nil {
2026-01-21 18:46:36 +03:00
log . Printf ( "createBoardItemHandler: Error creating board item: %v" , err )
2026-01-13 22:35:01 +03:00
sendErrorWithCORS ( w , "Error creating item" , http . StatusInternalServerError )
return
}
2026-01-21 18:46:36 +03:00
log . Printf ( "createBoardItemHandler: created wishlist item id=%d" , itemID )
2026-01-13 22:35:01 +03:00
// Сохраняем условия с user_id текущего пользователя
if len ( req . UnlockConditions ) > 0 {
2026-01-21 18:46:36 +03:00
log . Printf ( "createBoardItemHandler: saving %d conditions" , len ( req . UnlockConditions ) )
2026-01-13 22:35:01 +03:00
err = a . saveWishlistConditionsWithUserID ( tx , itemID , userID , req . UnlockConditions )
if err != nil {
2026-01-21 18:46:36 +03:00
log . Printf ( "createBoardItemHandler: Error saving wishlist conditions: %v" , err )
2026-01-13 22:35:01 +03:00
sendErrorWithCORS ( w , "Error saving conditions" , http . StatusInternalServerError )
return
}
2026-01-21 18:46:36 +03:00
log . Printf ( "createBoardItemHandler: conditions saved successfully" )
} else {
log . Printf ( "createBoardItemHandler: no conditions to save" )
2026-01-13 22:35:01 +03:00
}
if err := tx . Commit ( ) ; err != nil {
log . Printf ( "Error committing transaction: %v" , err )
sendErrorWithCORS ( w , "Error creating item" , http . StatusInternalServerError )
return
}
// Возвращаем созданное желание
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 {
2026-01-21 18:46:36 +03:00
log . Printf ( "saveWishlistConditionsWithUserID: wishlistItemID=%d, userID=%d, conditions=%d" ,
wishlistItemID , userID , len ( conditions ) )
2026-01-13 22:35:01 +03:00
for i , cond := range conditions {
displayOrder := i
if cond . DisplayOrder != nil {
displayOrder = * cond . DisplayOrder
}
2026-01-21 18:46:36 +03:00
log . Printf ( "saveWishlistConditionsWithUserID: processing condition %d - type='%s', taskID=%v, projectID=%v" ,
i , cond . Type , cond . TaskID , cond . ProjectID )
2026-01-13 22:35:01 +03:00
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 {
2026-01-21 18:46:36 +03:00
log . Printf ( "saveWishlistConditionsWithUserID: error creating task condition: %v" , err )
2026-01-13 22:35:01 +03:00
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 {
2026-01-21 18:46:36 +03:00
log . Printf ( "saveWishlistConditionsWithUserID: error linking task condition: %v" , err )
2026-01-13 22:35:01 +03:00
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 {
2026-01-21 18:46:36 +03:00
log . Printf ( "saveWishlistConditionsWithUserID: error creating score condition: %v" , err )
2026-01-13 22:35:01 +03:00
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 {
2026-01-21 18:46:36 +03:00
log . Printf ( "saveWishlistConditionsWithUserID: error linking score condition: %v" , err )
2026-01-13 22:35:01 +03:00
return fmt . Errorf ( "error linking score condition: %w" , err )
}
}
}
return nil
}
2026-01-11 21:12:26 +03:00
// LinkMetadataResponse структура ответа с метаданными ссылки
type LinkMetadataResponse struct {
Title string ` json:"title,omitempty" `
Image string ` json:"image,omitempty" `
Price * float64 ` json:"price,omitempty" `
Description string ` json:"description,omitempty" `
}
2026-01-22 20:11:29 +03:00
// extractMetadataViaHTTP извлекает метаданные через HTTP-запрос и парсинг HTML
// Это стандартный метод, используемый Telegram, Facebook и другими сервисами
func extractMetadataViaHTTP ( targetURL string ) ( * LinkMetadataResponse , error ) {
2026-01-11 21:12:26 +03:00
// Валидация URL
2026-01-22 20:11:29 +03:00
parsedURL , err := url . Parse ( targetURL )
2026-01-11 21:12:26 +03:00
if err != nil || parsedURL . Scheme == "" || parsedURL . Host == "" {
2026-01-22 20:11:29 +03:00
return nil , fmt . Errorf ( "invalid URL format: %s" , targetURL )
2026-01-11 21:12:26 +03:00
}
2026-01-13 17:19:00 +03:00
// HTTP клиент с увеличенным таймаутом и поддержкой редиректов
transport := & http . Transport {
DisableKeepAlives : false ,
MaxIdleConns : 10 ,
IdleConnTimeout : 90 * time . Second ,
}
2026-01-22 20:11:29 +03:00
2026-01-11 21:12:26 +03:00
client := & http . Client {
2026-01-26 18:45:58 +03:00
Timeout : 30 * time . Second ,
2026-01-13 17:19:00 +03:00
Transport : transport ,
CheckRedirect : func ( req * http . Request , via [ ] * http . Request ) error {
if len ( via ) >= 10 {
return fmt . Errorf ( "stopped after 10 redirects" )
}
return nil
} ,
2026-01-11 21:12:26 +03:00
}
2026-01-22 20:11:29 +03:00
httpReq , err := http . NewRequest ( "GET" , targetURL , nil )
2026-01-11 21:12:26 +03:00
if err != nil {
2026-01-22 20:11:29 +03:00
return nil , fmt . Errorf ( "error creating request: %w" , err )
2026-01-11 21:12:26 +03:00
}
2026-01-22 19:47:50 +03:00
// Устанавливаем заголовки, максимально имитирующие реальный браузер 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" )
2026-01-13 17:19:00 +03:00
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" )
2026-01-11 21:12:26 +03:00
httpReq . Header . Set ( "Accept-Language" , "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7" )
2026-01-22 19:47:50 +03:00
httpReq . Header . Set ( "Accept-Encoding" , "gzip, deflate, br, zstd" )
2026-01-13 17:19:00 +03:00
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" )
2026-01-22 19:47:50 +03:00
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" ` )
2026-01-13 17:19:00 +03:00
httpReq . Header . Set ( "Cache-Control" , "max-age=0" )
httpReq . Header . Set ( "DNT" , "1" )
2026-01-22 20:11:29 +03:00
2026-01-22 19:47:50 +03:00
if parsedURL . Host != "" {
referer := fmt . Sprintf ( "%s://%s/" , parsedURL . Scheme , parsedURL . Host )
httpReq . Header . Set ( "Referer" , referer )
}
2026-01-22 20:11:29 +03:00
2026-01-22 19:47:50 +03:00
time . Sleep ( 100 * time . Millisecond )
2026-01-11 21:12:26 +03:00
resp , err := client . Do ( httpReq )
if err != nil {
2026-01-22 20:11:29 +03:00
return nil , fmt . Errorf ( "error fetching URL: %w" , err )
2026-01-11 21:12:26 +03:00
}
defer resp . Body . Close ( )
2026-01-13 17:19:00 +03:00
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
2026-01-22 20:11:29 +03:00
return nil , fmt . Errorf ( "HTTP %d: %s" , resp . StatusCode , resp . Status )
2026-01-11 21:12:26 +03:00
}
limitedReader := io . LimitReader ( resp . Body , 512 * 1024 )
bodyBytes , err := io . ReadAll ( limitedReader )
if err != nil {
2026-01-22 20:11:29 +03:00
return nil , fmt . Errorf ( "error reading response: %w" , err )
2026-01-11 21:12:26 +03:00
}
2026-01-22 19:47:50 +03:00
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
}
}
}
2026-01-11 21:12:26 +03:00
body := string ( bodyBytes )
metadata := & LinkMetadataResponse { }
2026-01-22 20:11:29 +03:00
// Извлекаем Open Graph теги
2026-01-12 17:42:51 +03:00
ogTitleRe := regexp . MustCompile ( ` (?i)<meta[^>]*(?:property|name)\s*=\s*["']og:title["'][^>]*content\s*=\s*["']([^"']+)["'] ` )
ogTitleRe2 := regexp . MustCompile ( ` (?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:title["'] ` )
ogImageRe := regexp . MustCompile ( ` (?i)<meta[^>]*(?:property|name)\s*=\s*["']og:image["'][^>]*content\s*=\s*["']([^"']+)["'] ` )
ogImageRe2 := regexp . MustCompile ( ` (?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:image["'] ` )
ogDescRe := regexp . MustCompile ( ` (?i)<meta[^>]*(?:property|name)\s*=\s*["']og:description["'][^>]*content\s*=\s*["']([^"']+)["'] ` )
ogDescRe2 := regexp . MustCompile ( ` (?i)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']og:description["'] ` )
2026-01-11 21:12:26 +03:00
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 == "" {
2026-01-12 17:42:51 +03:00
titleRe := regexp . MustCompile ( ` (?i)<title[^>]*>([^<]+)</title> ` )
2026-01-11 21:12:26 +03:00
if matches := titleRe . FindStringSubmatch ( body ) ; len ( matches ) > 1 {
metadata . Title = strings . TrimSpace ( matches [ 1 ] )
2026-01-22 20:11:29 +03:00
if strings . Contains ( strings . ToLower ( metadata . Title ) , "робот" ) ||
strings . Contains ( strings . ToLower ( metadata . Title ) , "captcha" ) ||
strings . Contains ( strings . ToLower ( metadata . Title ) , "вы не робот" ) {
2026-01-22 19:47:50 +03:00
metadata . Title = ""
metadata . Image = ""
metadata . Description = ""
}
2026-01-11 21:12:26 +03:00
}
}
2026-01-12 17:42:51 +03:00
if metadata . Image == "" {
twitterImageRe := regexp . MustCompile ( ` (?i)<meta[^>]*(?: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)<meta[^>]*content\s*=\s*["']([^"']+)["'][^>]*(?:property|name)\s*=\s*["']twitter:image["'] ` )
if matches := twitterImageRe2 . FindStringSubmatch ( body ) ; len ( matches ) > 1 {
metadata . Image = strings . TrimSpace ( matches [ 1 ] )
}
}
}
2026-01-22 20:11:29 +03:00
// Поиск цены
2026-01-12 17:42:51 +03:00
jsonLdRe := regexp . MustCompile ( ` (?i)<script[^>]*type\s*=\s*["']application/ld\+json["'][^>]*>([^<]+)</script> ` )
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)<meta[^>]*(?: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
}
2026-01-11 21:12:26 +03:00
}
}
2026-01-22 20:11:29 +03:00
// Нормализуем URL изображения
2026-01-11 21:12:26 +03:00
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
}
}
2026-01-12 17:42:51 +03:00
metadata . Title = html . UnescapeString ( metadata . Title )
metadata . Description = html . UnescapeString ( metadata . Description )
2026-01-11 21:12:26 +03:00
2026-01-22 20:11:29 +03:00
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 )
}
2026-01-11 21:12:26 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( metadata )
}
2026-01-22 20:11:29 +03:00
// 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 )
}
2026-01-11 21:12:26 +03:00
// 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
}