All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m28s
14646 lines
483 KiB
Go
14646 lines
483 KiB
Go
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"compress/gzip"
|
||
"context"
|
||
"crypto/rand"
|
||
"database/sql"
|
||
"encoding/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"html"
|
||
"io"
|
||
"log"
|
||
"math"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
"unicode/utf16"
|
||
|
||
"image/jpeg"
|
||
|
||
mathrand "math/rand"
|
||
|
||
"github.com/chromedp/chromedp"
|
||
"github.com/disintegration/imaging"
|
||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||
"github.com/golang-jwt/jwt/v5"
|
||
"github.com/golang-migrate/migrate/v4"
|
||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||
"github.com/gorilla/mux"
|
||
"github.com/joho/godotenv"
|
||
"github.com/lib/pq"
|
||
_ "github.com/lib/pq"
|
||
"github.com/robfig/cron/v3"
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
// Палитра из 30 контрастных цветов для проектов (HEX формат)
|
||
// Используется для генерации случайного цвета при создании проекта
|
||
// Должна быть синхронизирована с frontend (projectUtils.js)
|
||
var projectColorsPalette = []string{
|
||
"#EF4444", // Красный
|
||
"#F97316", // Оранжевый
|
||
"#F59E0B", // Янтарный
|
||
"#EAB308", // Желтый
|
||
"#84CC16", // Лайм
|
||
"#22C55E", // Зеленый
|
||
"#10B981", // Изумрудный
|
||
"#14B8A6", // Бирюзовый
|
||
"#06B6D4", // Голубой
|
||
"#0EA5E9", // Небесный
|
||
"#3B82F6", // Синий
|
||
"#6366F1", // Индиго
|
||
"#8B5CF6", // Фиолетовый
|
||
"#A855F7", // Пурпурный
|
||
"#D946EF", // Фуксия
|
||
"#EC4899", // Розовый
|
||
"#F43F5E", // Розово-красный
|
||
"#DC2626", // Темно-красный
|
||
"#EA580C", // Темно-оранжевый
|
||
"#CA8A04", // Темно-желтый
|
||
"#65A30D", // Темно-лайм
|
||
"#16A34A", // Темно-зеленый
|
||
"#059669", // Темно-изумрудный
|
||
"#0D9488", // Темно-бирюзовый
|
||
"#0891B2", // Темно-голубой
|
||
"#0284C7", // Темно-небесный
|
||
"#2563EB", // Темно-синий
|
||
"#4F46E5", // Темно-индиго
|
||
"#7C3AED", // Темно-фиолетовый
|
||
"#9333EA", // Темно-пурпурный
|
||
}
|
||
|
||
type Word struct {
|
||
ID int `json:"id"`
|
||
Name string `json:"name"`
|
||
Translation string `json:"translation"`
|
||
Description string `json:"description"`
|
||
Success int `json:"success"`
|
||
Failure int `json:"failure"`
|
||
LastSuccess *string `json:"last_success_at,omitempty"`
|
||
LastFailure *string `json:"last_failure_at,omitempty"`
|
||
}
|
||
|
||
type WordRequest struct {
|
||
Name string `json:"name"`
|
||
Translation string `json:"translation"`
|
||
Description string `json:"description"`
|
||
DictionaryID *int `json:"dictionary_id,omitempty"`
|
||
}
|
||
|
||
type WordsRequest struct {
|
||
Words []WordRequest `json:"words"`
|
||
}
|
||
|
||
type TestProgressUpdate struct {
|
||
ID int `json:"id"`
|
||
Success int `json:"success"`
|
||
Failure int `json:"failure"`
|
||
LastSuccessAt *string `json:"last_success_at,omitempty"`
|
||
LastFailureAt *string `json:"last_failure_at,omitempty"`
|
||
}
|
||
|
||
type TestProgressRequest struct {
|
||
Words []TestProgressUpdate `json:"words"`
|
||
ConfigID *int `json:"config_id,omitempty"`
|
||
}
|
||
|
||
type Config struct {
|
||
ID int `json:"id"`
|
||
WordsCount int `json:"words_count"`
|
||
MaxCards *int `json:"max_cards,omitempty"`
|
||
}
|
||
|
||
type ConfigRequest struct {
|
||
WordsCount int `json:"words_count"`
|
||
MaxCards *int `json:"max_cards,omitempty"`
|
||
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||
}
|
||
|
||
type Dictionary struct {
|
||
ID int `json:"id"`
|
||
Name string `json:"name"`
|
||
WordsCount int `json:"wordsCount"`
|
||
}
|
||
|
||
type DictionaryRequest struct {
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
type TestConfigsAndDictionariesResponse struct {
|
||
Configs []Config `json:"configs"`
|
||
Dictionaries []Dictionary `json:"dictionaries"`
|
||
}
|
||
|
||
type WeeklyProjectStats struct {
|
||
ProjectName string `json:"project_name"`
|
||
TotalScore float64 `json:"total_score"`
|
||
MinGoalScore float64 `json:"min_goal_score"`
|
||
MaxGoalScore *float64 `json:"max_goal_score,omitempty"`
|
||
Priority *int `json:"priority,omitempty"`
|
||
CalculatedScore float64 `json:"calculated_score"`
|
||
TodayChange *float64 `json:"today_change,omitempty"`
|
||
Color string `json:"color"`
|
||
}
|
||
|
||
type GroupsProgress struct {
|
||
Group1 *float64 `json:"group1,omitempty"`
|
||
Group2 *float64 `json:"group2,omitempty"`
|
||
Group0 *float64 `json:"group0,omitempty"`
|
||
}
|
||
|
||
type WeeklyStatsResponse struct {
|
||
Total *float64 `json:"total,omitempty"`
|
||
GroupProgress1 *float64 `json:"group_progress_1,omitempty"`
|
||
GroupProgress2 *float64 `json:"group_progress_2,omitempty"`
|
||
GroupProgress0 *float64 `json:"group_progress_0,omitempty"`
|
||
Projects []WeeklyProjectStats `json:"projects"`
|
||
}
|
||
|
||
type MessagePostRequest struct {
|
||
Body struct {
|
||
Text string `json:"text"`
|
||
} `json:"body"`
|
||
}
|
||
|
||
type ProcessedNode struct {
|
||
Project string `json:"project"`
|
||
Score float64 `json:"score"`
|
||
}
|
||
|
||
type ProcessedEntry struct {
|
||
Text string `json:"text"`
|
||
CreatedDate string `json:"createdDate"`
|
||
Nodes []ProcessedNode `json:"nodes"`
|
||
Raw string `json:"raw"`
|
||
Markdown string `json:"markdown"`
|
||
}
|
||
|
||
type WeeklyGoalSetup struct {
|
||
ProjectName string `json:"project_name"`
|
||
MinGoalScore float64 `json:"min_goal_score"`
|
||
MaxGoalScore float64 `json:"max_goal_score"`
|
||
}
|
||
|
||
type Project struct {
|
||
ProjectID int `json:"project_id"`
|
||
ProjectName string `json:"project_name"`
|
||
Priority *int `json:"priority,omitempty"`
|
||
Color string `json:"color"`
|
||
}
|
||
|
||
type ProjectPriorityUpdate struct {
|
||
ID int `json:"id"`
|
||
Priority *int `json:"priority"`
|
||
}
|
||
|
||
type ProjectPriorityRequest struct {
|
||
Body []ProjectPriorityUpdate `json:"body"`
|
||
}
|
||
|
||
type FullStatisticsItem struct {
|
||
ProjectName string `json:"project_name"`
|
||
ReportYear int `json:"report_year"`
|
||
ReportWeek int `json:"report_week"`
|
||
TotalScore float64 `json:"total_score"`
|
||
MinGoalScore float64 `json:"min_goal_score"`
|
||
MaxGoalScore float64 `json:"max_goal_score"`
|
||
NormalizedTotalScore float64 `json:"normalized_total_score"`
|
||
Color string `json:"color"`
|
||
}
|
||
|
||
type TodayEntryNode struct {
|
||
ProjectName string `json:"project_name"`
|
||
Score float64 `json:"score"`
|
||
Index int `json:"index"`
|
||
}
|
||
|
||
type TodayEntry struct {
|
||
ID int `json:"id"`
|
||
Text string `json:"text"`
|
||
CreatedDate string `json:"created_date"`
|
||
Nodes []TodayEntryNode `json:"nodes"`
|
||
}
|
||
|
||
type TodoistWebhook struct {
|
||
EventName string `json:"event_name"`
|
||
EventData map[string]interface{} `json:"event_data"`
|
||
}
|
||
|
||
type TelegramEntity struct {
|
||
Type string `json:"type"`
|
||
Offset int `json:"offset"`
|
||
Length int `json:"length"`
|
||
}
|
||
|
||
type TelegramChat struct {
|
||
ID int64 `json:"id"`
|
||
}
|
||
|
||
type TelegramUser struct {
|
||
ID int64 `json:"id"`
|
||
}
|
||
|
||
type TelegramMessage struct {
|
||
Text string `json:"text"`
|
||
Entities []TelegramEntity `json:"entities"`
|
||
Chat TelegramChat `json:"chat"`
|
||
From *TelegramUser `json:"from,omitempty"`
|
||
}
|
||
|
||
type TelegramWebhook struct {
|
||
Message TelegramMessage `json:"message"`
|
||
}
|
||
|
||
// TelegramUpdate - структура для Telegram webhook (обычно это Update объект)
|
||
type TelegramUpdate struct {
|
||
UpdateID int `json:"update_id"`
|
||
Message *TelegramMessage `json:"message,omitempty"`
|
||
EditedMessage *TelegramMessage `json:"edited_message,omitempty"`
|
||
}
|
||
|
||
// Task structures
|
||
type Task struct {
|
||
ID int `json:"id"`
|
||
Name string `json:"name"`
|
||
Completed int `json:"completed"`
|
||
LastCompletedAt *string `json:"last_completed_at,omitempty"`
|
||
NextShowAt *string `json:"next_show_at,omitempty"`
|
||
RewardMessage *string `json:"reward_message,omitempty"`
|
||
ProgressionBase *float64 `json:"progression_base,omitempty"`
|
||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||
RepetitionDate *string `json:"repetition_date,omitempty"`
|
||
WishlistID *int `json:"wishlist_id,omitempty"`
|
||
ConfigID *int `json:"config_id,omitempty"`
|
||
RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями
|
||
Position *int `json:"position,omitempty"` // Position for subtasks
|
||
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
|
||
ProjectNames []string `json:"project_names"`
|
||
SubtasksCount int `json:"subtasks_count"`
|
||
HasProgression bool `json:"has_progression"`
|
||
AutoComplete bool `json:"auto_complete"`
|
||
}
|
||
|
||
type Reward struct {
|
||
ID int `json:"id"`
|
||
Position int `json:"position"`
|
||
ProjectName string `json:"project_name"`
|
||
Value float64 `json:"value"`
|
||
UseProgression bool `json:"use_progression"`
|
||
}
|
||
|
||
type Subtask struct {
|
||
Task Task `json:"task"`
|
||
Rewards []Reward `json:"rewards"`
|
||
}
|
||
|
||
type WishlistInfo struct {
|
||
ID int `json:"id"`
|
||
Name string `json:"name"`
|
||
Unlocked bool `json:"unlocked"`
|
||
}
|
||
|
||
type TaskDetail struct {
|
||
Task Task `json:"task"`
|
||
Rewards []Reward `json:"rewards"`
|
||
Subtasks []Subtask `json:"subtasks"`
|
||
WishlistInfo *WishlistInfo `json:"wishlist_info,omitempty"`
|
||
// Test-specific fields (only present if task has config_id)
|
||
WordsCount *int `json:"words_count,omitempty"`
|
||
MaxCards *int `json:"max_cards,omitempty"`
|
||
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||
// Draft fields (only present if draft exists)
|
||
DraftProgressionValue *float64 `json:"draft_progression_value,omitempty"`
|
||
DraftSubtasks []DraftSubtask `json:"draft_subtasks,omitempty"`
|
||
}
|
||
|
||
type RewardRequest struct {
|
||
Position int `json:"position"`
|
||
ProjectName string `json:"project_name"`
|
||
Value float64 `json:"value"`
|
||
UseProgression bool `json:"use_progression"`
|
||
}
|
||
|
||
type SubtaskRequest struct {
|
||
ID *int `json:"id,omitempty"`
|
||
Name *string `json:"name,omitempty"`
|
||
RewardMessage *string `json:"reward_message,omitempty"`
|
||
Position *int `json:"position,omitempty"`
|
||
Rewards []RewardRequest `json:"rewards,omitempty"`
|
||
}
|
||
|
||
type TaskRequest struct {
|
||
Name string `json:"name"`
|
||
ProgressionBase *float64 `json:"progression_base,omitempty"`
|
||
RewardMessage *string `json:"reward_message,omitempty"`
|
||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||
RepetitionDate *string `json:"repetition_date,omitempty"`
|
||
WishlistID *int `json:"wishlist_id,omitempty"`
|
||
RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями
|
||
Rewards []RewardRequest `json:"rewards,omitempty"`
|
||
Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
|
||
// Test-specific fields
|
||
IsTest bool `json:"is_test,omitempty"`
|
||
WordsCount *int `json:"words_count,omitempty"`
|
||
MaxCards *int `json:"max_cards,omitempty"`
|
||
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||
}
|
||
|
||
type CompleteTaskRequest struct {
|
||
Value *float64 `json:"value,omitempty"`
|
||
ChildrenTaskIDs []int `json:"children_task_ids,omitempty"`
|
||
}
|
||
|
||
type PostponeTaskRequest struct {
|
||
NextShowAt *string `json:"next_show_at"`
|
||
}
|
||
|
||
// ============================================
|
||
// Task Draft structures
|
||
// ============================================
|
||
|
||
type SaveDraftRequest struct {
|
||
ProgressionValue *float64 `json:"progression_value,omitempty"`
|
||
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"`
|
||
}
|
||
|
||
// ============================================
|
||
// Wishlist structures
|
||
// ============================================
|
||
|
||
type LinkedTask struct {
|
||
ID int `json:"id"`
|
||
Name string `json:"name"`
|
||
Completed int `json:"completed"`
|
||
NextShowAt *string `json:"next_show_at,omitempty"`
|
||
UserID *int `json:"user_id,omitempty"` // ID пользователя-владельца задачи
|
||
}
|
||
|
||
type WishlistItem struct {
|
||
ID int `json:"id"`
|
||
Name string `json:"name"`
|
||
Price *float64 `json:"price,omitempty"`
|
||
ImageURL *string `json:"image_url,omitempty"`
|
||
Link *string `json:"link,omitempty"`
|
||
Unlocked bool `json:"unlocked"`
|
||
Completed bool `json:"completed"`
|
||
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"` // Количество задач для этого желания
|
||
ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание
|
||
ProjectName *string `json:"project_name,omitempty"` // Название проекта
|
||
}
|
||
|
||
type UnlockConditionDisplay struct {
|
||
ID int `json:"id"`
|
||
Type string `json:"type"`
|
||
TaskID *int `json:"task_id,omitempty"` // ID задачи (для task_completion)
|
||
TaskName *string `json:"task_name,omitempty"`
|
||
TaskNextShowAt *string `json:"task_next_show_at,omitempty"` // Дата следующего показа задачи (для task_completion)
|
||
ProjectID *int `json:"project_id,omitempty"` // ID проекта (для project_points)
|
||
ProjectName *string `json:"project_name,omitempty"`
|
||
RequiredPoints *float64 `json:"required_points,omitempty"`
|
||
StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время
|
||
DisplayOrder int `json:"display_order"`
|
||
// Прогресс выполнения
|
||
CurrentPoints *float64 `json:"current_points,omitempty"` // Текущее количество баллов (для project_points)
|
||
TaskCompleted *bool `json:"task_completed,omitempty"` // Выполнена ли задача (для task_completion)
|
||
// Персональные цели
|
||
UserID *int `json:"user_id,omitempty"` // ID пользователя для персональных целей
|
||
UserName *string `json:"user_name,omitempty"` // Имя пользователя для персональных целей
|
||
// Срок разблокировки
|
||
WeeksText *string `json:"weeks_text,omitempty"` // Отформатированный текст срока разблокировки
|
||
}
|
||
|
||
type WishlistRequest struct {
|
||
Name string `json:"name"`
|
||
Price *float64 `json:"price,omitempty"`
|
||
Link *string `json:"link,omitempty"`
|
||
ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание
|
||
UnlockConditions []UnlockConditionRequest `json:"unlock_conditions,omitempty"`
|
||
}
|
||
|
||
type UnlockConditionRequest struct {
|
||
ID *int `json:"id,omitempty"` // ID существующего условия (для сохранения чужих условий)
|
||
Type string `json:"type"`
|
||
TaskID *int `json:"task_id,omitempty"`
|
||
ProjectID *int `json:"project_id,omitempty"`
|
||
RequiredPoints *float64 `json:"required_points,omitempty"`
|
||
StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время
|
||
DisplayOrder *int `json:"display_order,omitempty"`
|
||
}
|
||
|
||
type WishlistResponse struct {
|
||
Unlocked []WishlistItem `json:"unlocked"`
|
||
Locked []WishlistItem `json:"locked"`
|
||
Completed []WishlistItem `json:"completed,omitempty"`
|
||
CompletedCount int `json:"completed_count"` // Количество завершённых желаний
|
||
}
|
||
|
||
// ============================================
|
||
// Wishlist Boards (доски желаний)
|
||
// ============================================
|
||
|
||
type WishlistBoard struct {
|
||
ID int `json:"id"`
|
||
OwnerID int `json:"owner_id"`
|
||
OwnerName string `json:"owner_name,omitempty"`
|
||
Name string `json:"name"`
|
||
InviteEnabled bool `json:"invite_enabled"`
|
||
InviteToken *string `json:"invite_token,omitempty"`
|
||
InviteURL *string `json:"invite_url,omitempty"`
|
||
MemberCount int `json:"member_count"`
|
||
IsOwner bool `json:"is_owner"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
type BoardMember struct {
|
||
ID int `json:"id"`
|
||
UserID int `json:"user_id"`
|
||
Name string `json:"name"`
|
||
Email string `json:"email"`
|
||
JoinedAt time.Time `json:"joined_at"`
|
||
}
|
||
|
||
type BoardRequest struct {
|
||
Name string `json:"name"`
|
||
InviteEnabled *bool `json:"invite_enabled,omitempty"`
|
||
}
|
||
|
||
type BoardInviteInfo struct {
|
||
BoardID int `json:"board_id"`
|
||
Name string `json:"name"`
|
||
OwnerName string `json:"owner_name"`
|
||
MemberCount int `json:"member_count"`
|
||
}
|
||
|
||
type JoinBoardResponse struct {
|
||
Board WishlistBoard `json:"board"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// ============================================
|
||
// Helper functions for repetition_date
|
||
// ============================================
|
||
|
||
// calculateNextShowAtFromRepetitionDate calculates the next occurrence date based on repetition_date pattern
|
||
// Formats:
|
||
// - "N week" - Nth day of week (1=Monday, 7=Sunday)
|
||
// - "N month" - Nth day of month (1-31)
|
||
// - "MM-DD year" - specific date each year
|
||
func calculateNextShowAtFromRepetitionDate(repetitionDate string, fromDate time.Time) *time.Time {
|
||
if repetitionDate == "" {
|
||
return nil
|
||
}
|
||
|
||
parts := strings.Fields(strings.TrimSpace(repetitionDate))
|
||
if len(parts) < 2 {
|
||
return nil
|
||
}
|
||
|
||
value := parts[0]
|
||
unit := strings.ToLower(parts[1])
|
||
|
||
// Start from tomorrow at midnight
|
||
nextDate := time.Date(fromDate.Year(), fromDate.Month(), fromDate.Day(), 0, 0, 0, 0, fromDate.Location())
|
||
nextDate = nextDate.AddDate(0, 0, 1)
|
||
|
||
switch unit {
|
||
case "week":
|
||
// N-th day of week (1=Monday, 7=Sunday)
|
||
dayOfWeek, err := strconv.Atoi(value)
|
||
if err != nil || dayOfWeek < 1 || dayOfWeek > 7 {
|
||
return nil
|
||
}
|
||
// Go: Sunday=0, Monday=1, ..., Saturday=6
|
||
// Our format: Monday=1, ..., Sunday=7
|
||
// Convert our format to Go format
|
||
targetGoDay := dayOfWeek % 7 // Monday(1)->1, Sunday(7)->0
|
||
|
||
currentGoDay := int(nextDate.Weekday())
|
||
daysUntil := (targetGoDay - currentGoDay + 7) % 7
|
||
if daysUntil == 0 {
|
||
daysUntil = 7 // If same day, go to next week
|
||
}
|
||
nextDate = nextDate.AddDate(0, 0, daysUntil)
|
||
|
||
case "month":
|
||
// N-th day of month
|
||
dayOfMonth, err := strconv.Atoi(value)
|
||
if err != nil || dayOfMonth < 1 || dayOfMonth > 31 {
|
||
return nil
|
||
}
|
||
|
||
// Find the next occurrence of this day
|
||
for i := 0; i < 12; i++ { // Check up to 12 months ahead
|
||
// Get the last day of the current month
|
||
year, month, _ := nextDate.Date()
|
||
lastDayOfMonth := time.Date(year, month+1, 0, 0, 0, 0, 0, nextDate.Location()).Day()
|
||
|
||
// Use the actual day (capped at last day of month if needed)
|
||
actualDay := dayOfMonth
|
||
if actualDay > lastDayOfMonth {
|
||
actualDay = lastDayOfMonth
|
||
}
|
||
|
||
candidateDate := time.Date(year, month, actualDay, 0, 0, 0, 0, nextDate.Location())
|
||
|
||
// If this date is in the future (after fromDate), use it
|
||
if candidateDate.After(fromDate) {
|
||
nextDate = candidateDate
|
||
break
|
||
}
|
||
|
||
// Otherwise, try next month
|
||
nextDate = time.Date(year, month+1, 1, 0, 0, 0, 0, nextDate.Location())
|
||
}
|
||
|
||
case "year":
|
||
// MM-DD format (e.g., "02-01" for February 1st)
|
||
dateParts := strings.Split(value, "-")
|
||
if len(dateParts) != 2 {
|
||
return nil
|
||
}
|
||
month, err1 := strconv.Atoi(dateParts[0])
|
||
day, err2 := strconv.Atoi(dateParts[1])
|
||
if err1 != nil || err2 != nil || month < 1 || month > 12 || day < 1 || day > 31 {
|
||
return nil
|
||
}
|
||
|
||
// Find the next occurrence of this date
|
||
year := nextDate.Year()
|
||
candidateDate := time.Date(year, time.Month(month), day, 0, 0, 0, 0, nextDate.Location())
|
||
|
||
// If this year's date has passed, use next year
|
||
if !candidateDate.After(fromDate) {
|
||
candidateDate = time.Date(year+1, time.Month(month), day, 0, 0, 0, 0, nextDate.Location())
|
||
}
|
||
nextDate = candidateDate
|
||
|
||
default:
|
||
return nil
|
||
}
|
||
|
||
return &nextDate
|
||
}
|
||
|
||
// calculateNextShowAtFromRepetitionPeriod calculates the next show date by adding repetition_period to fromDate
|
||
// Format: PostgreSQL INTERVAL string (e.g., "1 day", "2 weeks", "3 months" or "3 mons")
|
||
// Note: PostgreSQL may return weeks as days (e.g., "7 days" instead of "1 week")
|
||
func calculateNextShowAtFromRepetitionPeriod(repetitionPeriod string, fromDate time.Time) *time.Time {
|
||
if repetitionPeriod == "" {
|
||
return nil
|
||
}
|
||
|
||
parts := strings.Fields(strings.TrimSpace(repetitionPeriod))
|
||
if len(parts) < 2 {
|
||
log.Printf("calculateNextShowAtFromRepetitionPeriod: invalid format, parts=%v", parts)
|
||
return nil
|
||
}
|
||
|
||
value, err := strconv.Atoi(parts[0])
|
||
if err != nil {
|
||
log.Printf("calculateNextShowAtFromRepetitionPeriod: failed to parse value '%s': %v", parts[0], err)
|
||
return nil
|
||
}
|
||
|
||
unit := strings.ToLower(parts[1])
|
||
log.Printf("calculateNextShowAtFromRepetitionPeriod: value=%d, unit='%s'", value, unit)
|
||
|
||
// Start from fromDate at midnight
|
||
nextDate := time.Date(fromDate.Year(), fromDate.Month(), fromDate.Day(), 0, 0, 0, 0, fromDate.Location())
|
||
|
||
switch unit {
|
||
case "minute", "minutes", "mins", "min":
|
||
nextDate = nextDate.Add(time.Duration(value) * time.Minute)
|
||
case "hour", "hours", "hrs", "hr":
|
||
nextDate = nextDate.Add(time.Duration(value) * time.Hour)
|
||
case "day", "days":
|
||
// PostgreSQL может возвращать недели как дни (например, "7 days" вместо "1 week")
|
||
// Если количество дней кратно 7, обрабатываем как недели
|
||
if value%7 == 0 && value >= 7 {
|
||
weeks := value / 7
|
||
nextDate = nextDate.AddDate(0, 0, weeks*7)
|
||
} else {
|
||
nextDate = nextDate.AddDate(0, 0, value)
|
||
}
|
||
case "week", "weeks", "wks", "wk":
|
||
nextDate = nextDate.AddDate(0, 0, value*7)
|
||
case "month", "months", "mons", "mon":
|
||
nextDate = nextDate.AddDate(0, value, 0)
|
||
log.Printf("calculateNextShowAtFromRepetitionPeriod: added %d months, result=%v", value, nextDate)
|
||
case "year", "years", "yrs", "yr":
|
||
nextDate = nextDate.AddDate(value, 0, 0)
|
||
default:
|
||
log.Printf("calculateNextShowAtFromRepetitionPeriod: unknown unit '%s'", unit)
|
||
return nil
|
||
}
|
||
|
||
return &nextDate
|
||
}
|
||
|
||
// ============================================
|
||
// Auth types
|
||
// ============================================
|
||
|
||
type User struct {
|
||
ID int `json:"id"`
|
||
Email string `json:"email"`
|
||
Name *string `json:"name,omitempty"`
|
||
PasswordHash string `json:"-"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
IsActive bool `json:"is_active"`
|
||
IsAdmin bool `json:"is_admin"`
|
||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||
}
|
||
|
||
type LoginRequest struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
}
|
||
|
||
type RegisterRequest struct {
|
||
Email string `json:"email"`
|
||
Password string `json:"password"`
|
||
Name *string `json:"name,omitempty"`
|
||
}
|
||
|
||
type TokenResponse struct {
|
||
AccessToken string `json:"access_token"`
|
||
RefreshToken string `json:"refresh_token"`
|
||
ExpiresIn int `json:"expires_in"`
|
||
User User `json:"user"`
|
||
}
|
||
|
||
type RefreshRequest struct {
|
||
RefreshToken string `json:"refresh_token"`
|
||
}
|
||
|
||
type UserResponse struct {
|
||
User User `json:"user"`
|
||
}
|
||
|
||
type JWTClaims struct {
|
||
UserID int `json:"user_id"`
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
// Context key for user ID
|
||
type contextKey string
|
||
|
||
const userIDKey contextKey = "user_id"
|
||
|
||
type App struct {
|
||
DB *sql.DB
|
||
webhookMutex sync.Mutex
|
||
lastWebhookTime map[int]time.Time // config_id -> last webhook time
|
||
telegramBot *tgbotapi.BotAPI
|
||
telegramBotUsername string
|
||
jwtSecret []byte
|
||
}
|
||
|
||
func setCORSHeaders(w http.ResponseWriter) {
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||
}
|
||
|
||
// ============================================
|
||
// Auth helper functions
|
||
// ============================================
|
||
|
||
func hashPassword(password string) (string, error) {
|
||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||
return string(bytes), err
|
||
}
|
||
|
||
func checkPasswordHash(password, hash string) bool {
|
||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||
return err == nil
|
||
}
|
||
|
||
func generateRefreshToken() (string, error) {
|
||
b := make([]byte, 32)
|
||
_, err := rand.Read(b)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return base64.URLEncoding.EncodeToString(b), nil
|
||
}
|
||
|
||
// generateWebhookToken generates a unique token for webhook URL identification
|
||
func generateWebhookToken() (string, error) {
|
||
b := make([]byte, 24) // 24 bytes = 32 chars in base64
|
||
_, err := rand.Read(b)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return base64.URLEncoding.EncodeToString(b), nil
|
||
}
|
||
|
||
// generateRandomProjectColor возвращает случайный цвет из предопределенной палитры
|
||
func generateRandomProjectColor() string {
|
||
if len(projectColorsPalette) == 0 {
|
||
return "#3B82F6" // Fallback цвет
|
||
}
|
||
return projectColorsPalette[mathrand.Intn(len(projectColorsPalette))]
|
||
}
|
||
|
||
func (a *App) generateAccessToken(userID int) (string, error) {
|
||
claims := JWTClaims{
|
||
UserID: userID,
|
||
RegisteredClaims: jwt.RegisteredClaims{
|
||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||
},
|
||
}
|
||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||
return token.SignedString(a.jwtSecret)
|
||
}
|
||
|
||
func (a *App) validateAccessToken(tokenString string) (*JWTClaims, error) {
|
||
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||
}
|
||
return a.jwtSecret, nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
|
||
return claims, nil
|
||
}
|
||
return nil, fmt.Errorf("invalid token")
|
||
}
|
||
|
||
// getUserIDFromContext extracts user ID from request context
|
||
func getUserIDFromContext(r *http.Request) (int, bool) {
|
||
userID, ok := r.Context().Value(userIDKey).(int)
|
||
return userID, ok
|
||
}
|
||
|
||
// ============================================
|
||
// Auth middleware
|
||
// ============================================
|
||
|
||
func (a *App) authMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// Handle CORS preflight
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
authHeader := r.Header.Get("Authorization")
|
||
if authHeader == "" {
|
||
sendErrorWithCORS(w, "Authorization header required", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
parts := strings.Split(authHeader, " ")
|
||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||
sendErrorWithCORS(w, "Invalid authorization header format", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
claims, err := a.validateAccessToken(parts[1])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid or expired token", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Add user_id to context
|
||
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
})
|
||
}
|
||
|
||
func (a *App) adminMiddleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
// Handle CORS preflight
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
// Get user_id from context (should be set by authMiddleware)
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Check if user is admin
|
||
var isAdmin bool
|
||
err := a.DB.QueryRow("SELECT is_admin FROM users WHERE id = $1", userID).Scan(&isAdmin)
|
||
if err != nil {
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "User not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
log.Printf("Error checking admin status: %v", err)
|
||
sendErrorWithCORS(w, "Database error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if !isAdmin {
|
||
sendErrorWithCORS(w, "Forbidden: Admin access required", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
next.ServeHTTP(w, r)
|
||
})
|
||
}
|
||
|
||
// ============================================
|
||
// Auth handlers
|
||
// ============================================
|
||
|
||
func (a *App) registerHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
var req RegisterRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.Email == "" || req.Password == "" {
|
||
sendErrorWithCORS(w, "Email and password are required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if len(req.Password) < 6 {
|
||
sendErrorWithCORS(w, "Password must be at least 6 characters", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Check if email already exists
|
||
var existingID int
|
||
err := a.DB.QueryRow("SELECT id FROM users WHERE email = $1", req.Email).Scan(&existingID)
|
||
if err == nil {
|
||
sendErrorWithCORS(w, "Email already registered", http.StatusConflict)
|
||
return
|
||
}
|
||
if err != sql.ErrNoRows {
|
||
log.Printf("Error checking existing user: %v", err)
|
||
sendErrorWithCORS(w, "Database error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Hash password
|
||
passwordHash, err := hashPassword(req.Password)
|
||
if err != nil {
|
||
log.Printf("Error hashing password: %v", err)
|
||
sendErrorWithCORS(w, "Error processing password", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Insert user
|
||
var user User
|
||
err = a.DB.QueryRow(`
|
||
INSERT INTO users (email, password_hash, name, created_at, updated_at, is_active, is_admin)
|
||
VALUES ($1, $2, $3, NOW(), NOW(), true, false)
|
||
RETURNING id, email, name, created_at, updated_at, is_active, is_admin, last_login_at
|
||
`, req.Email, passwordHash, req.Name).Scan(
|
||
&user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.IsAdmin, &user.LastLoginAt,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error inserting user: %v", err)
|
||
sendErrorWithCORS(w, "Error creating user", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Check if this is the first user - if so, claim all orphaned data
|
||
var userCount int
|
||
a.DB.QueryRow("SELECT COUNT(*) FROM users").Scan(&userCount)
|
||
if userCount == 1 {
|
||
log.Printf("First user registered (ID: %d), claiming all orphaned data", user.ID)
|
||
a.claimOrphanedData(user.ID)
|
||
}
|
||
|
||
// Generate tokens
|
||
accessToken, err := a.generateAccessToken(user.ID)
|
||
if err != nil {
|
||
log.Printf("Error generating access token: %v", err)
|
||
sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
refreshToken, err := generateRefreshToken()
|
||
if err != nil {
|
||
log.Printf("Error generating refresh token: %v", err)
|
||
sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Hash and store refresh token
|
||
refreshTokenHash, _ := hashPassword(refreshToken)
|
||
_, err = a.DB.Exec(`
|
||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
|
||
VALUES ($1, $2, $3)
|
||
`, user.ID, refreshTokenHash, nil)
|
||
if err != nil {
|
||
log.Printf("Error storing refresh token: %v", err)
|
||
}
|
||
|
||
// Update last login
|
||
a.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = $1", user.ID)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(TokenResponse{
|
||
AccessToken: accessToken,
|
||
RefreshToken: refreshToken,
|
||
ExpiresIn: 86400, // 24 hours
|
||
User: user,
|
||
})
|
||
}
|
||
|
||
func (a *App) loginHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
var req LoginRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.Email == "" || req.Password == "" {
|
||
sendErrorWithCORS(w, "Email and password are required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Find user
|
||
var user User
|
||
err := a.DB.QueryRow(`
|
||
SELECT id, email, password_hash, name, created_at, updated_at, is_active, is_admin, last_login_at
|
||
FROM users WHERE email = $1
|
||
`, req.Email).Scan(
|
||
&user.ID, &user.Email, &user.PasswordHash, &user.Name,
|
||
&user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.IsAdmin, &user.LastLoginAt,
|
||
)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Invalid email or password", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error finding user: %v", err)
|
||
sendErrorWithCORS(w, "Database error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if !user.IsActive {
|
||
sendErrorWithCORS(w, "Account is disabled", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Check password
|
||
if !checkPasswordHash(req.Password, user.PasswordHash) {
|
||
sendErrorWithCORS(w, "Invalid email or password", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Check if there is any orphaned data - claim it for this user
|
||
var orphanedDataCount int
|
||
a.DB.QueryRow(`
|
||
SELECT COUNT(*) FROM (
|
||
SELECT 1 FROM projects WHERE user_id IS NULL
|
||
UNION ALL SELECT 1 FROM entries WHERE user_id IS NULL
|
||
UNION ALL SELECT 1 FROM nodes WHERE user_id IS NULL
|
||
UNION ALL SELECT 1 FROM dictionaries WHERE user_id IS NULL
|
||
UNION ALL SELECT 1 FROM words WHERE user_id IS NULL
|
||
UNION ALL SELECT 1 FROM progress WHERE user_id IS NULL
|
||
UNION ALL SELECT 1 FROM configs WHERE user_id IS NULL
|
||
UNION ALL SELECT 1 FROM telegram_integrations WHERE user_id IS NULL
|
||
UNION ALL SELECT 1 FROM weekly_goals WHERE user_id IS NULL
|
||
LIMIT 1
|
||
) orphaned
|
||
`).Scan(&orphanedDataCount)
|
||
if orphanedDataCount > 0 {
|
||
log.Printf("User %d logging in, claiming orphaned data from all tables", user.ID)
|
||
a.claimOrphanedData(user.ID)
|
||
}
|
||
|
||
// Generate tokens
|
||
accessToken, err := a.generateAccessToken(user.ID)
|
||
if err != nil {
|
||
log.Printf("Error generating access token: %v", err)
|
||
sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
refreshToken, err := generateRefreshToken()
|
||
if err != nil {
|
||
log.Printf("Error generating refresh token: %v", err)
|
||
sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Hash and store refresh token
|
||
refreshTokenHash, _ := hashPassword(refreshToken)
|
||
_, err = a.DB.Exec(`
|
||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
|
||
VALUES ($1, $2, $3)
|
||
`, user.ID, refreshTokenHash, nil)
|
||
if err != nil {
|
||
log.Printf("Error storing refresh token: %v", err)
|
||
}
|
||
|
||
// Update last login
|
||
a.DB.Exec("UPDATE users SET last_login_at = NOW() WHERE id = $1", user.ID)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(TokenResponse{
|
||
AccessToken: accessToken,
|
||
RefreshToken: refreshToken,
|
||
ExpiresIn: 86400, // 24 hours
|
||
User: user,
|
||
})
|
||
}
|
||
|
||
func (a *App) refreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
var req RefreshRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.RefreshToken == "" {
|
||
sendErrorWithCORS(w, "Refresh token is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Find valid refresh token (expires_at is NULL for tokens without expiration)
|
||
rows, err := a.DB.Query(`
|
||
SELECT rt.id, rt.user_id, rt.token_hash, u.email, u.name, u.created_at, u.updated_at, u.is_active, u.is_admin, u.last_login_at
|
||
FROM refresh_tokens rt
|
||
JOIN users u ON rt.user_id = u.id
|
||
WHERE rt.expires_at IS NULL OR rt.expires_at > NOW()
|
||
`)
|
||
if err != nil {
|
||
log.Printf("Error querying refresh tokens: %v", err)
|
||
sendErrorWithCORS(w, "Database error", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
var foundTokenID int
|
||
var user User
|
||
var tokenFound bool
|
||
|
||
for rows.Next() {
|
||
var tokenID int
|
||
var tokenHash string
|
||
err := rows.Scan(&tokenID, &user.ID, &tokenHash, &user.Email, &user.Name,
|
||
&user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.IsAdmin, &user.LastLoginAt)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
if checkPasswordHash(req.RefreshToken, tokenHash) {
|
||
foundTokenID = tokenID
|
||
tokenFound = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if !tokenFound {
|
||
sendErrorWithCORS(w, "Invalid or expired refresh token", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
if !user.IsActive {
|
||
sendErrorWithCORS(w, "Account is disabled", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Generate new tokens FIRST before deleting old one to prevent race condition
|
||
accessToken, err := a.generateAccessToken(user.ID)
|
||
if err != nil {
|
||
log.Printf("Error generating access token: %v", err)
|
||
sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
refreshToken, err := generateRefreshToken()
|
||
if err != nil {
|
||
log.Printf("Error generating refresh token: %v", err)
|
||
sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Store new refresh token FIRST
|
||
refreshTokenHash, _ := hashPassword(refreshToken)
|
||
_, err = a.DB.Exec(`
|
||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
|
||
VALUES ($1, $2, $3)
|
||
`, user.ID, refreshTokenHash, nil)
|
||
if err != nil {
|
||
log.Printf("Error storing new refresh token: %v", err)
|
||
sendErrorWithCORS(w, "Error generating token", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Delete old refresh token AFTER new one is successfully stored
|
||
// This prevents race condition where multiple refresh requests might use the same token
|
||
a.DB.Exec("DELETE FROM refresh_tokens WHERE id = $1", foundTokenID)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(TokenResponse{
|
||
AccessToken: accessToken,
|
||
RefreshToken: refreshToken,
|
||
ExpiresIn: 86400, // 24 hours
|
||
User: user,
|
||
})
|
||
}
|
||
|
||
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Delete all refresh tokens for this user
|
||
a.DB.Exec("DELETE FROM refresh_tokens WHERE user_id = $1", userID)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{"message": "Logged out successfully"})
|
||
}
|
||
|
||
func (a *App) getMeHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var user User
|
||
err := a.DB.QueryRow(`
|
||
SELECT id, email, name, created_at, updated_at, is_active, is_admin, last_login_at
|
||
FROM users WHERE id = $1
|
||
`, userID).Scan(
|
||
&user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &user.IsAdmin, &user.LastLoginAt,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error finding user: %v", err)
|
||
sendErrorWithCORS(w, "User not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(UserResponse{User: user})
|
||
}
|
||
|
||
// claimOrphanedData assigns all data with NULL user_id to the specified user
|
||
func (a *App) claimOrphanedData(userID int) {
|
||
tables := []string{"projects", "entries", "nodes", "dictionaries", "words", "progress", "configs", "telegram_integrations", "weekly_goals"}
|
||
for _, table := range tables {
|
||
// First check if user_id column exists
|
||
var columnExists bool
|
||
err := a.DB.QueryRow(`
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM information_schema.columns
|
||
WHERE table_name = $1 AND column_name = 'user_id'
|
||
)
|
||
`, table).Scan(&columnExists)
|
||
|
||
if err != nil || !columnExists {
|
||
log.Printf("Skipping %s: user_id column does not exist (run migrations as table owner)", table)
|
||
continue
|
||
}
|
||
|
||
result, err := a.DB.Exec(fmt.Sprintf("UPDATE %s SET user_id = $1 WHERE user_id IS NULL", table), userID)
|
||
if err != nil {
|
||
log.Printf("Error claiming orphaned data in %s: %v", table, err)
|
||
} else {
|
||
rowsAffected, _ := result.RowsAffected()
|
||
if rowsAffected > 0 {
|
||
log.Printf("Claimed %d orphaned rows in %s for user %d", rowsAffected, table, userID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func sendErrorWithCORS(w http.ResponseWriter, message string, statusCode int) {
|
||
setCORSHeaders(w)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(statusCode)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"error": message,
|
||
})
|
||
}
|
||
|
||
func (a *App) getWordsHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Get dictionary_id from query parameter
|
||
dictionaryIDStr := r.URL.Query().Get("dictionary_id")
|
||
var dictionaryID *int
|
||
if dictionaryIDStr != "" {
|
||
if id, err := strconv.Atoi(dictionaryIDStr); err == nil {
|
||
dictionaryID = &id
|
||
}
|
||
}
|
||
|
||
query := `
|
||
SELECT
|
||
w.id,
|
||
w.name,
|
||
w.translation,
|
||
w.description,
|
||
COALESCE(p.success, 0) as success,
|
||
COALESCE(p.failure, 0) as failure,
|
||
CASE WHEN p.last_success_at IS NOT NULL THEN p.last_success_at::text ELSE NULL END as last_success_at,
|
||
CASE WHEN p.last_failure_at IS NOT NULL THEN p.last_failure_at::text ELSE NULL END as last_failure_at
|
||
FROM words w
|
||
JOIN dictionaries d ON w.dictionary_id = d.id
|
||
LEFT JOIN progress p ON w.id = p.word_id AND p.user_id = $1
|
||
WHERE d.user_id = $1 AND ($2::INTEGER IS NULL OR w.dictionary_id = $2)
|
||
ORDER BY w.id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID, dictionaryID)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
words := make([]Word, 0)
|
||
for rows.Next() {
|
||
var word Word
|
||
var lastSuccess, lastFailure sql.NullString
|
||
|
||
err := rows.Scan(
|
||
&word.ID,
|
||
&word.Name,
|
||
&word.Translation,
|
||
&word.Description,
|
||
&word.Success,
|
||
&word.Failure,
|
||
&lastSuccess,
|
||
&lastFailure,
|
||
)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if lastSuccess.Valid {
|
||
word.LastSuccess = &lastSuccess.String
|
||
}
|
||
if lastFailure.Valid {
|
||
word.LastFailure = &lastFailure.String
|
||
}
|
||
|
||
words = append(words, word)
|
||
}
|
||
|
||
setCORSHeaders(w)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(words)
|
||
}
|
||
|
||
func (a *App) addWordsHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req WordsRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding addWords request: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
log.Printf("addWords: user_id=%d, words_count=%d", userID, len(req.Words))
|
||
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Create default dictionary for user if needed
|
||
var defaultDictID int
|
||
err = tx.QueryRow(`
|
||
SELECT id FROM dictionaries WHERE user_id = $1 ORDER BY id LIMIT 1
|
||
`, userID).Scan(&defaultDictID)
|
||
if err == sql.ErrNoRows {
|
||
// Create default dictionary for user
|
||
log.Printf("Creating default dictionary for user_id=%d", userID)
|
||
err = tx.QueryRow(`
|
||
INSERT INTO dictionaries (name, user_id) VALUES ('Все слова', $1) RETURNING id
|
||
`, userID).Scan(&defaultDictID)
|
||
if err != nil {
|
||
log.Printf("Error creating default dictionary: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
log.Printf("Created default dictionary id=%d for user_id=%d", defaultDictID, userID)
|
||
} else if err != nil {
|
||
log.Printf("Error finding default dictionary: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
} else {
|
||
log.Printf("Using default dictionary id=%d for user_id=%d", defaultDictID, userID)
|
||
}
|
||
|
||
stmt, err := tx.Prepare(`
|
||
INSERT INTO words (name, translation, description, dictionary_id, user_id)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
RETURNING id
|
||
`)
|
||
if err != nil {
|
||
log.Printf("Error preparing insert statement: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer stmt.Close()
|
||
|
||
var addedCount int
|
||
for i, wordReq := range req.Words {
|
||
var id int
|
||
dictionaryID := defaultDictID
|
||
if wordReq.DictionaryID != nil {
|
||
dictionaryID = *wordReq.DictionaryID
|
||
// Проверяем, что словарь принадлежит пользователю
|
||
var dictUserID int
|
||
err := tx.QueryRow(`
|
||
SELECT user_id FROM dictionaries WHERE id = $1
|
||
`, dictionaryID).Scan(&dictUserID)
|
||
if err == sql.ErrNoRows {
|
||
log.Printf("Dictionary %d not found for word %d", dictionaryID, i)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Dictionary %d not found", dictionaryID), http.StatusBadRequest)
|
||
return
|
||
} else if err != nil {
|
||
log.Printf("Error checking dictionary ownership: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if dictUserID != userID {
|
||
log.Printf("Dictionary %d belongs to user %d, but request from user %d", dictionaryID, dictUserID, userID)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Dictionary %d does not belong to user", dictionaryID), http.StatusForbidden)
|
||
return
|
||
}
|
||
}
|
||
err := stmt.QueryRow(wordReq.Name, wordReq.Translation, wordReq.Description, dictionaryID, userID).Scan(&id)
|
||
if err != nil {
|
||
log.Printf("Error inserting word %d (name='%s', dict_id=%d, user_id=%d): %v", i, wordReq.Name, dictionaryID, userID, err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
addedCount++
|
||
log.Printf("Successfully added word id=%d: name='%s', dict_id=%d", id, wordReq.Name, dictionaryID)
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
log.Printf("Error committing transaction: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
log.Printf("Successfully added %d words for user_id=%d", addedCount, userID)
|
||
|
||
setCORSHeaders(w)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": fmt.Sprintf("Added %d words", addedCount),
|
||
"added": addedCount,
|
||
})
|
||
}
|
||
|
||
func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) {
|
||
log.Printf("getTestWordsHandler called: %s %s", r.Method, r.URL.Path)
|
||
setCORSHeaders(w)
|
||
|
||
if r.Method == "OPTIONS" {
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Get config_id from query parameter (required)
|
||
configIDStr := r.URL.Query().Get("config_id")
|
||
if configIDStr == "" {
|
||
sendErrorWithCORS(w, "config_id parameter is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
configID, err := strconv.Atoi(configIDStr)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "invalid config_id parameter", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Get words_count from config (verify ownership)
|
||
var wordsCount int
|
||
err = a.DB.QueryRow("SELECT words_count FROM configs WHERE id = $1 AND user_id = $2", configID, userID).Scan(&wordsCount)
|
||
if err != nil {
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "config not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Get dictionary IDs for this config
|
||
var dictionaryIDs []int
|
||
dictQuery := `
|
||
SELECT dictionary_id
|
||
FROM config_dictionaries
|
||
WHERE config_id = $1
|
||
`
|
||
dictRows, err := a.DB.Query(dictQuery, configID)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer dictRows.Close()
|
||
|
||
for dictRows.Next() {
|
||
var dictID int
|
||
if err := dictRows.Scan(&dictID); err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
dictionaryIDs = append(dictionaryIDs, dictID)
|
||
}
|
||
|
||
// If no dictionaries are selected for config, use all dictionaries (no filter)
|
||
var dictFilter string
|
||
var dictArgs []interface{}
|
||
if len(dictionaryIDs) > 0 {
|
||
placeholders := make([]string, len(dictionaryIDs))
|
||
for i := range dictionaryIDs {
|
||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||
}
|
||
dictFilter = fmt.Sprintf("w.dictionary_id IN (%s)", strings.Join(placeholders, ","))
|
||
for _, dictID := range dictionaryIDs {
|
||
dictArgs = append(dictArgs, dictID)
|
||
}
|
||
} else {
|
||
dictFilter = "1=1" // No filter
|
||
}
|
||
|
||
// Calculate group sizes (use ceiling to ensure we don't lose words due to rounding)
|
||
group1Count := int(float64(wordsCount) * 0.3) // 30%
|
||
group2Count := int(float64(wordsCount) * 0.4) // 40%
|
||
// group3Count is calculated dynamically based on actual words collected from groups 1 and 2
|
||
|
||
// Base query parts
|
||
baseSelect := `
|
||
w.id,
|
||
w.name,
|
||
w.translation,
|
||
w.description,
|
||
COALESCE(p.success, 0) as success,
|
||
COALESCE(p.failure, 0) as failure,
|
||
CASE WHEN p.last_success_at IS NOT NULL THEN p.last_success_at::text ELSE NULL END as last_success_at,
|
||
CASE WHEN p.last_failure_at IS NOT NULL THEN p.last_failure_at::text ELSE NULL END as last_failure_at
|
||
`
|
||
baseFrom := fmt.Sprintf(`
|
||
FROM words w
|
||
JOIN dictionaries d ON w.dictionary_id = d.id AND d.user_id = %d
|
||
LEFT JOIN progress p ON w.id = p.word_id AND p.user_id = %d
|
||
WHERE `, userID, userID) + dictFilter
|
||
|
||
// Group 1: success <= 3, sorted by success ASC, then last_success_at ASC (NULL first)
|
||
group1Query := `
|
||
SELECT ` + baseSelect + `
|
||
` + baseFrom + `
|
||
AND COALESCE(p.success, 0) <= 3
|
||
ORDER BY
|
||
COALESCE(p.success, 0) ASC,
|
||
CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END,
|
||
p.last_success_at ASC
|
||
LIMIT $` + fmt.Sprintf("%d", len(dictArgs)+1)
|
||
|
||
group1Args := append(dictArgs, group1Count*2) // Get more to ensure uniqueness
|
||
group1Rows, err := a.DB.Query(group1Query, group1Args...)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer group1Rows.Close()
|
||
|
||
group1Words := make([]Word, 0)
|
||
group1WordIDs := make(map[int]bool)
|
||
for group1Rows.Next() && len(group1Words) < group1Count {
|
||
var word Word
|
||
var lastSuccess, lastFailure sql.NullString
|
||
|
||
err := group1Rows.Scan(
|
||
&word.ID,
|
||
&word.Name,
|
||
&word.Translation,
|
||
&word.Description,
|
||
&word.Success,
|
||
&word.Failure,
|
||
&lastSuccess,
|
||
&lastFailure,
|
||
)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if lastSuccess.Valid {
|
||
word.LastSuccess = &lastSuccess.String
|
||
}
|
||
if lastFailure.Valid {
|
||
word.LastFailure = &lastFailure.String
|
||
}
|
||
|
||
group1Words = append(group1Words, word)
|
||
group1WordIDs[word.ID] = true
|
||
}
|
||
|
||
// Group 2: sorted by (failure + 1)/(success + 1) DESC, take top 40%
|
||
// Exclude words already in group1
|
||
group2Exclude := ""
|
||
group2Args := make([]interface{}, 0)
|
||
group2Args = append(group2Args, dictArgs...)
|
||
if len(group1WordIDs) > 0 {
|
||
excludePlaceholders := make([]string, 0, len(group1WordIDs))
|
||
idx := len(dictArgs) + 1
|
||
for wordID := range group1WordIDs {
|
||
excludePlaceholders = append(excludePlaceholders, fmt.Sprintf("$%d", idx))
|
||
group2Args = append(group2Args, wordID)
|
||
idx++
|
||
}
|
||
group2Exclude = " AND w.id NOT IN (" + strings.Join(excludePlaceholders, ",") + ")"
|
||
}
|
||
|
||
group2Query := `
|
||
SELECT ` + baseSelect + `
|
||
` + baseFrom + `
|
||
` + group2Exclude + `
|
||
ORDER BY
|
||
(COALESCE(p.failure, 0) + 1.0) / (COALESCE(p.success, 0) + 1.0) DESC,
|
||
CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END,
|
||
p.last_success_at ASC
|
||
LIMIT $` + fmt.Sprintf("%d", len(group2Args)+1)
|
||
|
||
group2Args = append(group2Args, group2Count)
|
||
group2Rows, err := a.DB.Query(group2Query, group2Args...)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer group2Rows.Close()
|
||
|
||
group2Words := make([]Word, 0)
|
||
group2WordIDs := make(map[int]bool)
|
||
for group2Rows.Next() {
|
||
var word Word
|
||
var lastSuccess, lastFailure sql.NullString
|
||
|
||
err := group2Rows.Scan(
|
||
&word.ID,
|
||
&word.Name,
|
||
&word.Translation,
|
||
&word.Description,
|
||
&word.Success,
|
||
&word.Failure,
|
||
&lastSuccess,
|
||
&lastFailure,
|
||
)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if lastSuccess.Valid {
|
||
word.LastSuccess = &lastSuccess.String
|
||
}
|
||
if lastFailure.Valid {
|
||
word.LastFailure = &lastFailure.String
|
||
}
|
||
|
||
group2Words = append(group2Words, word)
|
||
group2WordIDs[word.ID] = true
|
||
}
|
||
|
||
// Group 3: All remaining words, sorted by last_success_at ASC (NULL first)
|
||
// Exclude words already in group1 and group2
|
||
allExcludedIDs := make(map[int]bool)
|
||
for id := range group1WordIDs {
|
||
allExcludedIDs[id] = true
|
||
}
|
||
for id := range group2WordIDs {
|
||
allExcludedIDs[id] = true
|
||
}
|
||
|
||
group3Exclude := ""
|
||
group3Args := make([]interface{}, 0)
|
||
group3Args = append(group3Args, dictArgs...)
|
||
if len(allExcludedIDs) > 0 {
|
||
excludePlaceholders := make([]string, 0, len(allExcludedIDs))
|
||
idx := len(dictArgs) + 1
|
||
for wordID := range allExcludedIDs {
|
||
excludePlaceholders = append(excludePlaceholders, fmt.Sprintf("$%d", idx))
|
||
group3Args = append(group3Args, wordID)
|
||
idx++
|
||
}
|
||
group3Exclude = " AND w.id NOT IN (" + strings.Join(excludePlaceholders, ",") + ")"
|
||
}
|
||
|
||
// Calculate how many words we still need from group 3
|
||
wordsCollected := len(group1Words) + len(group2Words)
|
||
group3Needed := wordsCount - wordsCollected
|
||
|
||
log.Printf("Word selection: wordsCount=%d, group1=%d, group2=%d, collected=%d, group3Needed=%d",
|
||
wordsCount, len(group1Words), len(group2Words), wordsCollected, group3Needed)
|
||
|
||
group3Words := make([]Word, 0)
|
||
if group3Needed > 0 {
|
||
group3Query := `
|
||
SELECT ` + baseSelect + `
|
||
` + baseFrom + `
|
||
` + group3Exclude + `
|
||
ORDER BY
|
||
CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END,
|
||
p.last_success_at ASC
|
||
LIMIT $` + fmt.Sprintf("%d", len(group3Args)+1)
|
||
|
||
group3Args = append(group3Args, group3Needed)
|
||
group3Rows, err := a.DB.Query(group3Query, group3Args...)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer group3Rows.Close()
|
||
|
||
for group3Rows.Next() {
|
||
var word Word
|
||
var lastSuccess, lastFailure sql.NullString
|
||
|
||
err := group3Rows.Scan(
|
||
&word.ID,
|
||
&word.Name,
|
||
&word.Translation,
|
||
&word.Description,
|
||
&word.Success,
|
||
&word.Failure,
|
||
&lastSuccess,
|
||
&lastFailure,
|
||
)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if lastSuccess.Valid {
|
||
word.LastSuccess = &lastSuccess.String
|
||
}
|
||
if lastFailure.Valid {
|
||
word.LastFailure = &lastFailure.String
|
||
}
|
||
|
||
group3Words = append(group3Words, word)
|
||
}
|
||
}
|
||
|
||
// Combine all groups
|
||
words := make([]Word, 0)
|
||
words = append(words, group1Words...)
|
||
words = append(words, group2Words...)
|
||
words = append(words, group3Words...)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(words)
|
||
}
|
||
|
||
func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request) {
|
||
log.Printf("updateTestProgressHandler called: %s %s", r.Method, r.URL.Path)
|
||
setCORSHeaders(w)
|
||
|
||
if r.Method == "OPTIONS" {
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req TestProgressRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding request: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
log.Printf("Received %d word updates, config_id: %v, user_id: %d", len(req.Words), req.ConfigID, userID)
|
||
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Create unique constraint for (word_id, user_id) if not exists
|
||
tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS progress_word_user_unique ON progress(word_id, user_id)")
|
||
|
||
stmt, err := tx.Prepare(`
|
||
INSERT INTO progress (word_id, user_id, success, failure, last_success_at, last_failure_at)
|
||
VALUES ($1, $2, $3, $4, $5, $6)
|
||
ON CONFLICT (word_id, user_id)
|
||
DO UPDATE SET
|
||
success = EXCLUDED.success,
|
||
failure = EXCLUDED.failure,
|
||
last_success_at = COALESCE(EXCLUDED.last_success_at, progress.last_success_at),
|
||
last_failure_at = COALESCE(EXCLUDED.last_failure_at, progress.last_failure_at)
|
||
`)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer stmt.Close()
|
||
|
||
for _, wordUpdate := range req.Words {
|
||
// Convert pointers to values for logging
|
||
lastSuccessStr := "nil"
|
||
if wordUpdate.LastSuccessAt != nil {
|
||
lastSuccessStr = *wordUpdate.LastSuccessAt
|
||
}
|
||
lastFailureStr := "nil"
|
||
if wordUpdate.LastFailureAt != nil {
|
||
lastFailureStr = *wordUpdate.LastFailureAt
|
||
}
|
||
log.Printf("Updating word %d: success=%d, failure=%d, last_success_at=%s, last_failure_at=%s",
|
||
wordUpdate.ID, wordUpdate.Success, wordUpdate.Failure, lastSuccessStr, lastFailureStr)
|
||
|
||
// Convert pointers to sql.NullString for proper NULL handling
|
||
var lastSuccess, lastFailure interface{}
|
||
if wordUpdate.LastSuccessAt != nil && *wordUpdate.LastSuccessAt != "" {
|
||
lastSuccess = *wordUpdate.LastSuccessAt
|
||
} else {
|
||
lastSuccess = nil
|
||
}
|
||
if wordUpdate.LastFailureAt != nil && *wordUpdate.LastFailureAt != "" {
|
||
lastFailure = *wordUpdate.LastFailureAt
|
||
} else {
|
||
lastFailure = nil
|
||
}
|
||
|
||
_, err := stmt.Exec(
|
||
wordUpdate.ID,
|
||
userID,
|
||
wordUpdate.Success,
|
||
wordUpdate.Failure,
|
||
lastSuccess,
|
||
lastFailure,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error executing update for word %d: %v", wordUpdate.ID, err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Note: Reward message is now sent via completeTaskHandler when the test task is automatically completed.
|
||
// The config_id is kept in the request for potential future use, but we no longer send messages here
|
||
// to avoid duplicate messages (one from test completion, one from task completion).
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Progress updated successfully",
|
||
})
|
||
}
|
||
|
||
func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
query := `
|
||
SELECT id, words_count, max_cards
|
||
FROM configs
|
||
WHERE user_id = $1
|
||
ORDER BY id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
configs := make([]Config, 0)
|
||
for rows.Next() {
|
||
var config Config
|
||
var maxCards sql.NullInt64
|
||
err := rows.Scan(
|
||
&config.ID,
|
||
&config.WordsCount,
|
||
&maxCards,
|
||
)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if maxCards.Valid {
|
||
maxCardsVal := int(maxCards.Int64)
|
||
config.MaxCards = &maxCardsVal
|
||
}
|
||
configs = append(configs, config)
|
||
}
|
||
|
||
setCORSHeaders(w)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(configs)
|
||
}
|
||
|
||
func (a *App) getDictionariesHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
query := `
|
||
SELECT
|
||
d.id,
|
||
d.name,
|
||
COALESCE(COUNT(w.id), 0) as words_count
|
||
FROM dictionaries d
|
||
LEFT JOIN words w ON d.id = w.dictionary_id
|
||
WHERE d.user_id = $1
|
||
GROUP BY d.id, d.name
|
||
ORDER BY d.id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
dictionaries := make([]Dictionary, 0)
|
||
for rows.Next() {
|
||
var dict Dictionary
|
||
err := rows.Scan(
|
||
&dict.ID,
|
||
&dict.Name,
|
||
&dict.WordsCount,
|
||
)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
dictionaries = append(dictionaries, dict)
|
||
}
|
||
|
||
setCORSHeaders(w)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(dictionaries)
|
||
}
|
||
|
||
func (a *App) addDictionaryHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req DictionaryRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.Name == "" {
|
||
sendErrorWithCORS(w, "Имя словаря обязательно", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
var id int
|
||
err := a.DB.QueryRow(`
|
||
INSERT INTO dictionaries (name, user_id)
|
||
VALUES ($1, $2)
|
||
RETURNING id
|
||
`, req.Name, userID).Scan(&id)
|
||
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"id": id,
|
||
"name": req.Name,
|
||
})
|
||
}
|
||
|
||
func (a *App) updateDictionaryHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
dictionaryID := vars["id"]
|
||
|
||
var req DictionaryRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.Name == "" {
|
||
sendErrorWithCORS(w, "Имя словаря обязательно", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
result, err := a.DB.Exec(`
|
||
UPDATE dictionaries
|
||
SET name = $1
|
||
WHERE id = $2 AND user_id = $3
|
||
`, req.Name, dictionaryID, userID)
|
||
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
rowsAffected, err := result.RowsAffected()
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if rowsAffected == 0 {
|
||
http.Error(w, "Dictionary not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Dictionary updated successfully",
|
||
})
|
||
}
|
||
|
||
func (a *App) deleteDictionaryHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
dictionaryID := vars["id"]
|
||
|
||
// Prevent deletion of default dictionary (id = 0)
|
||
if dictionaryID == "0" {
|
||
sendErrorWithCORS(w, "Cannot delete default dictionary", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Verify ownership
|
||
var ownerID int
|
||
err := a.DB.QueryRow("SELECT user_id FROM dictionaries WHERE id = $1", dictionaryID).Scan(&ownerID)
|
||
if err != nil || ownerID != userID {
|
||
sendErrorWithCORS(w, "Dictionary not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Delete all words from this dictionary (progress will be deleted automatically due to CASCADE)
|
||
_, err = tx.Exec(`
|
||
DELETE FROM words
|
||
WHERE dictionary_id = $1
|
||
`, dictionaryID)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Delete all config-dictionary associations (will be deleted automatically due to CASCADE, but doing explicitly for clarity)
|
||
_, err = tx.Exec(`
|
||
DELETE FROM config_dictionaries
|
||
WHERE dictionary_id = $1
|
||
`, dictionaryID)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Delete the dictionary
|
||
result, err := tx.Exec("DELETE FROM dictionaries WHERE id = $1", dictionaryID)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
rowsAffected, err := result.RowsAffected()
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if rowsAffected == 0 {
|
||
sendErrorWithCORS(w, "Dictionary not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Dictionary deleted successfully. All words and configuration associations have been deleted.",
|
||
})
|
||
}
|
||
|
||
func (a *App) getConfigDictionariesHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
configID := vars["id"]
|
||
|
||
query := `
|
||
SELECT dictionary_id
|
||
FROM config_dictionaries
|
||
WHERE config_id = $1
|
||
ORDER BY dictionary_id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, configID)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
dictionaryIDs := make([]int, 0)
|
||
for rows.Next() {
|
||
var dictID int
|
||
err := rows.Scan(&dictID)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
dictionaryIDs = append(dictionaryIDs, dictID)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"dictionary_ids": dictionaryIDs,
|
||
})
|
||
}
|
||
|
||
func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
log.Printf("getTestConfigsAndDictionariesHandler: Unauthorized request")
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
log.Printf("getTestConfigsAndDictionariesHandler called, user: %d", userID)
|
||
|
||
// Get configs
|
||
configsQuery := `
|
||
SELECT id, words_count, max_cards
|
||
FROM configs
|
||
WHERE user_id = $1
|
||
ORDER BY id
|
||
`
|
||
|
||
configsRows, err := a.DB.Query(configsQuery, userID)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer configsRows.Close()
|
||
|
||
configs := make([]Config, 0)
|
||
for configsRows.Next() {
|
||
var config Config
|
||
var maxCards sql.NullInt64
|
||
err := configsRows.Scan(
|
||
&config.ID,
|
||
&config.WordsCount,
|
||
&maxCards,
|
||
)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if maxCards.Valid {
|
||
maxCardsVal := int(maxCards.Int64)
|
||
config.MaxCards = &maxCardsVal
|
||
}
|
||
configs = append(configs, config)
|
||
}
|
||
|
||
// Get dictionaries
|
||
dictsQuery := `
|
||
SELECT
|
||
d.id,
|
||
d.name,
|
||
COALESCE(COUNT(w.id), 0) as words_count
|
||
FROM dictionaries d
|
||
LEFT JOIN words w ON d.id = w.dictionary_id
|
||
WHERE d.user_id = $1
|
||
GROUP BY d.id, d.name
|
||
ORDER BY d.id
|
||
`
|
||
|
||
dictsRows, err := a.DB.Query(dictsQuery, userID)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer dictsRows.Close()
|
||
|
||
dictionaries := make([]Dictionary, 0)
|
||
for dictsRows.Next() {
|
||
var dict Dictionary
|
||
err := dictsRows.Scan(
|
||
&dict.ID,
|
||
&dict.Name,
|
||
&dict.WordsCount,
|
||
)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
dictionaries = append(dictionaries, dict)
|
||
}
|
||
|
||
response := TestConfigsAndDictionariesResponse{
|
||
Configs: configs,
|
||
Dictionaries: dictionaries,
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req ConfigRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.WordsCount <= 0 {
|
||
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
var id int
|
||
err = tx.QueryRow(`
|
||
INSERT INTO configs (words_count, max_cards, user_id)
|
||
VALUES ($1, $2, $3)
|
||
RETURNING id
|
||
`, req.WordsCount, req.MaxCards, userID).Scan(&id)
|
||
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Insert dictionary associations if provided
|
||
if len(req.DictionaryIDs) > 0 {
|
||
stmt, err := tx.Prepare(`
|
||
INSERT INTO config_dictionaries (config_id, dictionary_id)
|
||
VALUES ($1, $2)
|
||
`)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer stmt.Close()
|
||
|
||
for _, dictID := range req.DictionaryIDs {
|
||
_, err := stmt.Exec(id, dictID)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Config created successfully",
|
||
"id": id,
|
||
})
|
||
}
|
||
|
||
func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
configID := vars["id"]
|
||
|
||
// Verify ownership
|
||
var ownerID int
|
||
err := a.DB.QueryRow("SELECT user_id FROM configs WHERE id = $1", configID).Scan(&ownerID)
|
||
if err != nil || ownerID != userID {
|
||
sendErrorWithCORS(w, "Config not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
var req ConfigRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.WordsCount <= 0 {
|
||
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
result, err := tx.Exec(`
|
||
UPDATE configs
|
||
SET words_count = $1, max_cards = $2
|
||
WHERE id = $3
|
||
`, req.WordsCount, req.MaxCards, configID)
|
||
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
rowsAffected, err := result.RowsAffected()
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if rowsAffected == 0 {
|
||
http.Error(w, "Config not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
// Delete existing dictionary associations
|
||
_, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", configID)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Insert new dictionary associations if provided
|
||
if len(req.DictionaryIDs) > 0 {
|
||
stmt, err := tx.Prepare(`
|
||
INSERT INTO config_dictionaries (config_id, dictionary_id)
|
||
VALUES ($1, $2)
|
||
`)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer stmt.Close()
|
||
|
||
for _, dictID := range req.DictionaryIDs {
|
||
_, err := stmt.Exec(configID, dictID)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Config updated successfully",
|
||
})
|
||
}
|
||
|
||
func (a *App) deleteConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
configID := vars["id"]
|
||
|
||
result, err := a.DB.Exec("DELETE FROM configs WHERE id = $1 AND user_id = $2", configID, userID)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
rowsAffected, err := result.RowsAffected()
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if rowsAffected == 0 {
|
||
sendErrorWithCORS(w, "Config not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Config deleted successfully",
|
||
})
|
||
}
|
||
|
||
func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
log.Printf("getWeeklyStatsHandler called from %s, path: %s, user: %d", r.RemoteAddr, r.URL.Path, userID)
|
||
|
||
// Получаем данные текущей недели напрямую из nodes
|
||
currentWeekScores, err := a.getCurrentWeekScores(userID)
|
||
if err != nil {
|
||
log.Printf("Error getting current week scores: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Получаем сегодняшние приросты
|
||
todayScores, err := a.getTodayScores(userID)
|
||
if err != nil {
|
||
log.Printf("Error getting today scores: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
query := `
|
||
SELECT
|
||
p.id AS project_id,
|
||
p.name AS project_name,
|
||
-- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv
|
||
COALESCE(wr.total_score, 0.0000) AS total_score,
|
||
wg.min_goal_score,
|
||
wg.max_goal_score,
|
||
wg.priority AS priority,
|
||
p.color
|
||
FROM
|
||
projects p
|
||
LEFT JOIN
|
||
weekly_goals wg ON wg.project_id = p.id
|
||
AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||
LEFT JOIN
|
||
weekly_report_mv wr
|
||
ON p.id = wr.project_id
|
||
AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year
|
||
AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week
|
||
WHERE
|
||
p.deleted = FALSE AND p.user_id = $1
|
||
ORDER BY
|
||
total_score DESC
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID)
|
||
if err != nil {
|
||
log.Printf("Error querying weekly stats: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
projects := make([]WeeklyProjectStats, 0)
|
||
// Группы для расчета среднего по priority
|
||
groups := make(map[int][]float64)
|
||
|
||
for rows.Next() {
|
||
var project WeeklyProjectStats
|
||
var projectID int
|
||
var minGoalScore sql.NullFloat64
|
||
var maxGoalScore sql.NullFloat64
|
||
var priority sql.NullInt64
|
||
|
||
err := rows.Scan(
|
||
&projectID,
|
||
&project.ProjectName,
|
||
&project.TotalScore,
|
||
&minGoalScore,
|
||
&maxGoalScore,
|
||
&priority,
|
||
&project.Color,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning weekly stats row: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Объединяем данные: если есть данные текущей недели, используем их вместо MV
|
||
if currentWeekScore, exists := currentWeekScores[projectID]; exists {
|
||
project.TotalScore = currentWeekScore
|
||
}
|
||
|
||
// Добавляем сегодняшний прирост
|
||
if todayScore, exists := todayScores[projectID]; exists && todayScore != 0 {
|
||
project.TodayChange = &todayScore
|
||
}
|
||
|
||
if minGoalScore.Valid {
|
||
project.MinGoalScore = minGoalScore.Float64
|
||
} else {
|
||
project.MinGoalScore = 0
|
||
}
|
||
|
||
if maxGoalScore.Valid {
|
||
maxGoalVal := maxGoalScore.Float64
|
||
project.MaxGoalScore = &maxGoalVal
|
||
}
|
||
|
||
var priorityVal int
|
||
if priority.Valid {
|
||
priorityVal = int(priority.Int64)
|
||
project.Priority = &priorityVal
|
||
}
|
||
|
||
// Расчет calculated_score по формуле из n8n
|
||
totalScore := project.TotalScore
|
||
minGoalScoreVal := project.MinGoalScore
|
||
var maxGoalScoreVal float64
|
||
if project.MaxGoalScore != nil {
|
||
maxGoalScoreVal = *project.MaxGoalScore
|
||
}
|
||
|
||
// Параметры бонуса в зависимости от priority
|
||
var extraBonusLimit float64 = 40
|
||
if priorityVal == 1 {
|
||
extraBonusLimit = 100
|
||
} else if priorityVal == 2 {
|
||
extraBonusLimit = 70
|
||
}
|
||
|
||
// Расчет базового прогресса
|
||
var baseProgress float64
|
||
if minGoalScoreVal > 0 {
|
||
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
|
||
}
|
||
|
||
// Расчет экстра прогресса
|
||
var extraProgress float64
|
||
denominator := maxGoalScoreVal - minGoalScoreVal
|
||
if denominator > 0 && totalScore > minGoalScoreVal {
|
||
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
||
extraProgress = (excess / denominator) * extraBonusLimit
|
||
}
|
||
|
||
resultScore := baseProgress + extraProgress
|
||
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
||
|
||
// Группировка для итогового расчета
|
||
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
|
||
if minGoalScoreVal > 0 {
|
||
if _, exists := groups[priorityVal]; !exists {
|
||
groups[priorityVal] = make([]float64, 0)
|
||
}
|
||
groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore)
|
||
}
|
||
|
||
projects = append(projects, project)
|
||
}
|
||
|
||
// Вычисляем проценты для каждой группы
|
||
groupsProgress := calculateGroupsProgress(groups)
|
||
|
||
// Вычисляем общий процент выполнения
|
||
total := calculateOverallProgress(groupsProgress)
|
||
|
||
response := WeeklyStatsResponse{
|
||
Total: total,
|
||
GroupProgress1: groupsProgress.Group1,
|
||
GroupProgress2: groupsProgress.Group2,
|
||
GroupProgress0: groupsProgress.Group0,
|
||
Projects: projects,
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
// runMigrations applies database migrations using golang-migrate
|
||
func (a *App) runMigrations() error {
|
||
migrationsPath := "migrations"
|
||
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
|
||
// Try alternative path for Docker
|
||
migrationsPath = "/migrations"
|
||
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
|
||
return fmt.Errorf("migrations directory not found")
|
||
}
|
||
}
|
||
|
||
// Get database connection string from environment
|
||
dbHost := getEnv("DB_HOST", "localhost")
|
||
dbPort := getEnv("DB_PORT", "5432")
|
||
dbUser := getEnv("DB_USER", "playeng")
|
||
dbPassword := getEnv("DB_PASSWORD", "playeng")
|
||
dbName := getEnv("DB_NAME", "playeng")
|
||
|
||
// Build database URL with proper encoding for special characters in password
|
||
// url.UserPassword properly encodes special characters like ^, @, etc.
|
||
userInfo := url.UserPassword(dbUser, dbPassword)
|
||
databaseURL := fmt.Sprintf("postgres://%s@%s:%s/%s?sslmode=disable",
|
||
userInfo.String(), dbHost, dbPort, dbName)
|
||
|
||
// Create migrate instance
|
||
m, err := migrate.New(
|
||
fmt.Sprintf("file://%s", migrationsPath),
|
||
databaseURL,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to initialize migrations: %w", err)
|
||
}
|
||
defer m.Close()
|
||
|
||
// Check if schema_migrations table exists and its state
|
||
var schemaExists bool
|
||
var currentVersion int64
|
||
var isDirty bool
|
||
err = a.DB.QueryRow(`
|
||
SELECT EXISTS (
|
||
SELECT FROM information_schema.tables
|
||
WHERE table_schema = 'public'
|
||
AND table_name = 'schema_migrations'
|
||
)
|
||
`).Scan(&schemaExists)
|
||
if err != nil {
|
||
log.Printf("Warning: Could not check schema_migrations table: %v", err)
|
||
}
|
||
|
||
// If schema_migrations exists, check its state
|
||
if schemaExists {
|
||
err = a.DB.QueryRow(`
|
||
SELECT version, dirty FROM schema_migrations LIMIT 1
|
||
`).Scan(¤tVersion, &isDirty)
|
||
if err != nil {
|
||
log.Printf("Warning: Could not read schema_migrations: %v", err)
|
||
schemaExists = false // Treat as if it doesn't exist
|
||
} else if isDirty {
|
||
// Database is in dirty state - fix it
|
||
log.Println("Detected dirty migration state, fixing...")
|
||
_, err = a.DB.Exec(`
|
||
UPDATE schema_migrations SET dirty = false WHERE version = $1
|
||
`, currentVersion)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to fix dirty migration state: %w", err)
|
||
}
|
||
log.Printf("Fixed dirty migration state for version %d", currentVersion)
|
||
// Continue to apply migrations normally
|
||
}
|
||
}
|
||
|
||
// If schema_migrations doesn't exist, check if database has existing tables
|
||
// This handles the case when an old dump was restored
|
||
if !schemaExists {
|
||
var tableCount int
|
||
err = a.DB.QueryRow(`
|
||
SELECT COUNT(*) FROM information_schema.tables
|
||
WHERE table_schema = 'public'
|
||
AND table_name NOT IN ('schema_migrations')
|
||
`).Scan(&tableCount)
|
||
if err == nil && tableCount > 0 {
|
||
// Database has existing tables but no schema_migrations
|
||
// This means an old dump was restored - set version to 1 without applying migration
|
||
log.Println("Detected existing database schema without schema_migrations table")
|
||
log.Println("Setting migration version to 1 (baseline) without applying migration")
|
||
|
||
// Create schema_migrations table and set version to 1
|
||
_, err = a.DB.Exec(`
|
||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
version bigint NOT NULL PRIMARY KEY,
|
||
dirty boolean NOT NULL
|
||
)
|
||
`)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create schema_migrations table: %w", err)
|
||
}
|
||
|
||
_, err = a.DB.Exec(`
|
||
INSERT INTO schema_migrations (version, dirty)
|
||
VALUES (1, false)
|
||
ON CONFLICT (version) DO UPDATE SET dirty = false
|
||
`)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to set migration version: %w", err)
|
||
}
|
||
|
||
log.Println("Migration version set to 1 (baseline) for existing database")
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// Apply migrations normally
|
||
if err := m.Up(); err != nil {
|
||
if err == migrate.ErrNoChange {
|
||
log.Println("Database is up to date, no migrations to apply")
|
||
return nil
|
||
}
|
||
return fmt.Errorf("failed to apply migrations: %w", err)
|
||
}
|
||
|
||
log.Println("Database migrations applied successfully")
|
||
return nil
|
||
}
|
||
|
||
func (a *App) initDB() error {
|
||
// This function is kept for backward compatibility but does nothing
|
||
// Database schema is now managed by golang-migrate
|
||
return nil
|
||
}
|
||
|
||
func (a *App) initAuthDB() error {
|
||
// Clean up expired refresh tokens (only those with expiration date set)
|
||
// This is business logic that should run on startup
|
||
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
|
||
return nil
|
||
}
|
||
|
||
func (a *App) initPlayLifeDB() error {
|
||
// This function is kept for backward compatibility but does nothing
|
||
// Database schema is now managed by golang-migrate
|
||
return nil
|
||
}
|
||
|
||
// DEPRECATED: All migration functions below are no longer used
|
||
// Database migrations are now handled by golang-migrate
|
||
// These functions are kept for reference only and will be removed in future versions
|
||
//
|
||
// NOTE: Functions applyMigration012-029 have been removed as they are no longer needed.
|
||
// All database schema is now managed by golang-migrate baseline migration.
|
||
|
||
// DEPRECATED: initPlayLifeDBOld is no longer used - schema is managed by golang-migrate
|
||
func (a *App) initPlayLifeDBOld() error {
|
||
// This function is kept for backward compatibility but does nothing
|
||
// Database schema is now managed by golang-migrate
|
||
return nil
|
||
}
|
||
|
||
// startWeeklyGoalsScheduler запускает планировщик для автоматической фиксации целей на неделю
|
||
// каждый понедельник в 6:00 утра в указанном часовом поясе
|
||
func (a *App) startWeeklyGoalsScheduler() {
|
||
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
||
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||
log.Printf("Loading timezone for weekly goals scheduler: '%s'", timezoneStr)
|
||
|
||
// Загружаем часовой пояс
|
||
loc, err := time.LoadLocation(timezoneStr)
|
||
if err != nil {
|
||
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
|
||
log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'")
|
||
loc = time.UTC
|
||
timezoneStr = "UTC"
|
||
} else {
|
||
log.Printf("Weekly goals scheduler timezone set to: %s", timezoneStr)
|
||
}
|
||
|
||
// Логируем текущее время в указанном часовом поясе для проверки
|
||
now := time.Now().In(loc)
|
||
log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
|
||
log.Printf("Next weekly goals setup will be on Monday at: 06:00 %s (cron: '0 6 * * 1')", timezoneStr)
|
||
|
||
// Создаем планировщик с указанным часовым поясом
|
||
c := cron.New(cron.WithLocation(loc))
|
||
|
||
// Добавляем задачу: каждый понедельник в 6:00 утра
|
||
// Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1)
|
||
_, err = c.AddFunc("0 6 * * 1", func() {
|
||
now := time.Now().In(loc)
|
||
log.Printf("Scheduled task: Refreshing materialized views and setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
|
||
|
||
// Сначала обновляем MV (чтобы в ней были данные прошлой недели)
|
||
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
|
||
if err != nil {
|
||
log.Printf("Error refreshing materialized view: %v", err)
|
||
} else {
|
||
log.Printf("Materialized view refreshed successfully")
|
||
}
|
||
|
||
// Обновляем 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")
|
||
}
|
||
|
||
// Затем настраиваем цели на новую неделю
|
||
if err := a.setupWeeklyGoals(); err != nil {
|
||
log.Printf("Error in scheduled weekly goals setup: %v", err)
|
||
}
|
||
})
|
||
if err != nil {
|
||
log.Printf("Warning: Failed to add weekly goals scheduler: %v", err)
|
||
return
|
||
}
|
||
|
||
// Запускаем планировщик
|
||
c.Start()
|
||
log.Println("Weekly goals scheduler started")
|
||
}
|
||
|
||
// getCurrentWeekScores получает данные текущей недели напрямую из таблицы nodes для конкретного пользователя
|
||
// Возвращает map[project_id]total_score для текущей недели
|
||
func (a *App) getCurrentWeekScores(userID int) (map[int]float64, error) {
|
||
query := `
|
||
SELECT
|
||
n.project_id,
|
||
COALESCE(SUM(n.score), 0) AS total_score
|
||
FROM nodes n
|
||
JOIN projects p ON n.project_id = p.id
|
||
WHERE
|
||
p.deleted = FALSE
|
||
AND p.user_id = $1
|
||
AND n.user_id = $1
|
||
AND EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||
AND EXTRACT(WEEK FROM n.created_date)::INTEGER = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||
GROUP BY n.project_id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID)
|
||
if err != nil {
|
||
log.Printf("Error querying current week scores: %v", err)
|
||
return nil, fmt.Errorf("error querying current week scores: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
scores := make(map[int]float64)
|
||
for rows.Next() {
|
||
var projectID int
|
||
var totalScore float64
|
||
if err := rows.Scan(&projectID, &totalScore); err != nil {
|
||
log.Printf("Error scanning current week scores row: %v", err)
|
||
return nil, fmt.Errorf("error scanning current week scores row: %w", err)
|
||
}
|
||
scores[projectID] = totalScore
|
||
}
|
||
|
||
return scores, nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// getCurrentWeekScoresAllUsers получает данные текущей недели для всех пользователей
|
||
// Возвращает map[project_id]total_score для текущей недели
|
||
func (a *App) getCurrentWeekScoresAllUsers() (map[int]float64, error) {
|
||
query := `
|
||
SELECT
|
||
n.project_id,
|
||
COALESCE(SUM(n.score), 0) AS total_score
|
||
FROM nodes n
|
||
JOIN projects p ON n.project_id = p.id
|
||
WHERE
|
||
p.deleted = FALSE
|
||
AND EXTRACT(ISOYEAR FROM n.created_date)::INTEGER = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||
AND EXTRACT(WEEK FROM n.created_date)::INTEGER = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||
GROUP BY n.project_id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query)
|
||
if err != nil {
|
||
log.Printf("Error querying current week scores for all users: %v", err)
|
||
return nil, fmt.Errorf("error querying current week scores for all users: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
scores := make(map[int]float64)
|
||
for rows.Next() {
|
||
var projectID int
|
||
var totalScore float64
|
||
if err := rows.Scan(&projectID, &totalScore); err != nil {
|
||
log.Printf("Error scanning current week scores row: %v", err)
|
||
return nil, fmt.Errorf("error scanning current week scores row: %w", err)
|
||
}
|
||
scores[projectID] = totalScore
|
||
}
|
||
|
||
return scores, nil
|
||
}
|
||
|
||
// getWeeklyStatsData получает данные о проектах и их целях (без HTTP обработки)
|
||
func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
|
||
// Получаем данные текущей недели для всех пользователей
|
||
currentWeekScores, err := a.getCurrentWeekScoresAllUsers()
|
||
if err != nil {
|
||
log.Printf("Error getting current week scores: %v", err)
|
||
return nil, fmt.Errorf("error getting current week scores: %w", err)
|
||
}
|
||
|
||
// Получаем сегодняшние приросты для всех пользователей
|
||
todayScores, err := a.getTodayScoresAllUsers()
|
||
if err != nil {
|
||
log.Printf("Error getting today scores: %v", err)
|
||
return nil, fmt.Errorf("error getting today scores: %w", err)
|
||
}
|
||
|
||
query := `
|
||
SELECT
|
||
p.id AS project_id,
|
||
p.name AS project_name,
|
||
-- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv
|
||
COALESCE(wr.total_score, 0.0000) AS total_score,
|
||
wg.min_goal_score,
|
||
wg.max_goal_score,
|
||
wg.priority AS priority,
|
||
p.color
|
||
FROM
|
||
projects p
|
||
LEFT JOIN
|
||
weekly_goals wg ON wg.project_id = p.id
|
||
AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||
LEFT JOIN
|
||
weekly_report_mv wr
|
||
ON p.id = wr.project_id
|
||
AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year
|
||
AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week
|
||
WHERE
|
||
p.deleted = FALSE
|
||
ORDER BY
|
||
total_score DESC
|
||
`
|
||
|
||
rows, err := a.DB.Query(query)
|
||
if err != nil {
|
||
log.Printf("Error querying weekly stats: %v", err)
|
||
return nil, fmt.Errorf("error querying weekly stats: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
projects := make([]WeeklyProjectStats, 0)
|
||
// Группы для расчета среднего по priority
|
||
groups := make(map[int][]float64)
|
||
|
||
for rows.Next() {
|
||
var project WeeklyProjectStats
|
||
var projectID int
|
||
var minGoalScore sql.NullFloat64
|
||
var maxGoalScore sql.NullFloat64
|
||
var priority sql.NullInt64
|
||
|
||
err := rows.Scan(
|
||
&projectID,
|
||
&project.ProjectName,
|
||
&project.TotalScore,
|
||
&minGoalScore,
|
||
&maxGoalScore,
|
||
&priority,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning weekly stats row: %v", err)
|
||
return nil, fmt.Errorf("error scanning weekly stats row: %w", err)
|
||
}
|
||
|
||
// Объединяем данные: если есть данные текущей недели, используем их вместо MV
|
||
if currentWeekScore, exists := currentWeekScores[projectID]; exists {
|
||
project.TotalScore = currentWeekScore
|
||
}
|
||
|
||
// Добавляем сегодняшний прирост
|
||
if todayScore, exists := todayScores[projectID]; exists && todayScore != 0 {
|
||
project.TodayChange = &todayScore
|
||
}
|
||
|
||
if minGoalScore.Valid {
|
||
project.MinGoalScore = minGoalScore.Float64
|
||
} else {
|
||
project.MinGoalScore = 0
|
||
}
|
||
|
||
if maxGoalScore.Valid {
|
||
maxGoalVal := maxGoalScore.Float64
|
||
project.MaxGoalScore = &maxGoalVal
|
||
}
|
||
|
||
var priorityVal int
|
||
if priority.Valid {
|
||
priorityVal = int(priority.Int64)
|
||
project.Priority = &priorityVal
|
||
}
|
||
|
||
// Расчет calculated_score по формуле из n8n
|
||
totalScore := project.TotalScore
|
||
minGoalScoreVal := project.MinGoalScore
|
||
var maxGoalScoreVal float64
|
||
if project.MaxGoalScore != nil {
|
||
maxGoalScoreVal = *project.MaxGoalScore
|
||
}
|
||
|
||
// Параметры бонуса в зависимости от priority
|
||
var extraBonusLimit float64 = 40
|
||
if priorityVal == 1 {
|
||
extraBonusLimit = 100
|
||
} else if priorityVal == 2 {
|
||
extraBonusLimit = 70
|
||
}
|
||
|
||
// Расчет базового прогресса
|
||
var baseProgress float64
|
||
if minGoalScoreVal > 0 {
|
||
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
|
||
}
|
||
|
||
// Расчет экстра прогресса
|
||
var extraProgress float64
|
||
denominator := maxGoalScoreVal - minGoalScoreVal
|
||
if denominator > 0 && totalScore > minGoalScoreVal {
|
||
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
||
extraProgress = (excess / denominator) * extraBonusLimit
|
||
}
|
||
|
||
resultScore := baseProgress + extraProgress
|
||
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
||
|
||
// Группировка для итогового расчета
|
||
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
|
||
if minGoalScoreVal > 0 {
|
||
if _, exists := groups[priorityVal]; !exists {
|
||
groups[priorityVal] = make([]float64, 0)
|
||
}
|
||
groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore)
|
||
}
|
||
|
||
projects = append(projects, project)
|
||
}
|
||
|
||
// Вычисляем проценты для каждой группы
|
||
groupsProgress := calculateGroupsProgress(groups)
|
||
|
||
// Вычисляем общий процент выполнения
|
||
total := calculateOverallProgress(groupsProgress)
|
||
|
||
response := WeeklyStatsResponse{
|
||
Total: total,
|
||
GroupProgress1: groupsProgress.Group1,
|
||
GroupProgress2: groupsProgress.Group2,
|
||
GroupProgress0: groupsProgress.Group0,
|
||
Projects: projects,
|
||
}
|
||
|
||
return &response, nil
|
||
}
|
||
|
||
// getWeeklyStatsDataForUser получает данные о проектах для конкретного пользователя
|
||
func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error) {
|
||
// Получаем данные текущей недели напрямую из nodes
|
||
currentWeekScores, err := a.getCurrentWeekScores(userID)
|
||
if err != nil {
|
||
log.Printf("Error getting current week scores: %v", err)
|
||
return nil, fmt.Errorf("error getting current week scores: %w", err)
|
||
}
|
||
|
||
// Получаем сегодняшние приросты
|
||
todayScores, err := a.getTodayScores(userID)
|
||
if err != nil {
|
||
log.Printf("Error getting today scores: %v", err)
|
||
return nil, fmt.Errorf("error getting today scores: %w", err)
|
||
}
|
||
|
||
query := `
|
||
SELECT
|
||
p.id AS project_id,
|
||
p.name AS project_name,
|
||
COALESCE(wr.total_score, 0.0000) AS total_score,
|
||
wg.min_goal_score,
|
||
wg.max_goal_score,
|
||
wg.priority AS priority,
|
||
p.color
|
||
FROM
|
||
projects p
|
||
LEFT JOIN
|
||
weekly_goals wg ON wg.project_id = p.id
|
||
AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||
LEFT JOIN
|
||
weekly_report_mv wr
|
||
ON p.id = wr.project_id
|
||
AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year
|
||
AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week
|
||
WHERE
|
||
p.deleted = FALSE AND p.user_id = $1
|
||
ORDER BY
|
||
total_score DESC
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error querying weekly stats: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
projects := make([]WeeklyProjectStats, 0)
|
||
groups := make(map[int][]float64)
|
||
|
||
for rows.Next() {
|
||
var project WeeklyProjectStats
|
||
var projectID int
|
||
var minGoalScore sql.NullFloat64
|
||
var maxGoalScore sql.NullFloat64
|
||
var priority sql.NullInt64
|
||
|
||
err := rows.Scan(
|
||
&projectID,
|
||
&project.ProjectName,
|
||
&project.TotalScore,
|
||
&minGoalScore,
|
||
&maxGoalScore,
|
||
&priority,
|
||
&project.Color,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error scanning weekly stats row: %w", err)
|
||
}
|
||
|
||
// Объединяем данные: если есть данные текущей недели, используем их вместо MV
|
||
if currentWeekScore, exists := currentWeekScores[projectID]; exists {
|
||
project.TotalScore = currentWeekScore
|
||
}
|
||
|
||
// Добавляем сегодняшний прирост
|
||
if todayScore, exists := todayScores[projectID]; exists && todayScore != 0 {
|
||
project.TodayChange = &todayScore
|
||
}
|
||
|
||
if minGoalScore.Valid {
|
||
project.MinGoalScore = minGoalScore.Float64
|
||
} else {
|
||
project.MinGoalScore = 0
|
||
}
|
||
|
||
if maxGoalScore.Valid {
|
||
maxGoalVal := maxGoalScore.Float64
|
||
project.MaxGoalScore = &maxGoalVal
|
||
}
|
||
|
||
var priorityVal int
|
||
if priority.Valid {
|
||
priorityVal = int(priority.Int64)
|
||
project.Priority = &priorityVal
|
||
}
|
||
|
||
// Расчет calculated_score
|
||
totalScore := project.TotalScore
|
||
minGoalScoreVal := project.MinGoalScore
|
||
var maxGoalScoreVal float64
|
||
if project.MaxGoalScore != nil {
|
||
maxGoalScoreVal = *project.MaxGoalScore
|
||
}
|
||
|
||
// Параметры бонуса в зависимости от priority
|
||
var extraBonusLimit float64 = 40
|
||
if priorityVal == 1 {
|
||
extraBonusLimit = 100
|
||
} else if priorityVal == 2 {
|
||
extraBonusLimit = 70
|
||
}
|
||
|
||
// Расчет базового прогресса
|
||
var baseProgress float64
|
||
if minGoalScoreVal > 0 {
|
||
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
|
||
}
|
||
|
||
// Расчет экстра прогресса
|
||
var extraProgress float64
|
||
denominator := maxGoalScoreVal - minGoalScoreVal
|
||
if denominator > 0 && totalScore > minGoalScoreVal {
|
||
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
|
||
extraProgress = (excess / denominator) * extraBonusLimit
|
||
}
|
||
|
||
resultScore := baseProgress + extraProgress
|
||
project.CalculatedScore = roundToTwoDecimals(resultScore)
|
||
projects = append(projects, project)
|
||
|
||
// Группировка для итогового расчета
|
||
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
|
||
if minGoalScoreVal > 0 {
|
||
if _, exists := groups[priorityVal]; !exists {
|
||
groups[priorityVal] = make([]float64, 0)
|
||
}
|
||
groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore)
|
||
}
|
||
}
|
||
|
||
// Вычисляем проценты для каждой группы
|
||
groupsProgress := calculateGroupsProgress(groups)
|
||
|
||
// Вычисляем общий процент выполнения
|
||
total := calculateOverallProgress(groupsProgress)
|
||
|
||
response := WeeklyStatsResponse{
|
||
Total: total,
|
||
GroupProgress1: groupsProgress.Group1,
|
||
GroupProgress2: groupsProgress.Group2,
|
||
GroupProgress0: groupsProgress.Group0,
|
||
Projects: projects,
|
||
}
|
||
|
||
return &response, nil
|
||
}
|
||
|
||
// formatDailyReport форматирует данные проектов в сообщение для Telegram
|
||
// Формат аналогичен JS коду из n8n
|
||
func (a *App) formatDailyReport(data *WeeklyStatsResponse) string {
|
||
if data == nil || len(data.Projects) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// Заголовок сообщения
|
||
markdownMessage := "*📈 Отчет:*\n\n"
|
||
|
||
// Простой вывод списка проектов
|
||
for _, item := range data.Projects {
|
||
projectName := item.ProjectName
|
||
if projectName == "" {
|
||
projectName = "Без названия"
|
||
}
|
||
|
||
actualScore := item.TotalScore
|
||
minGoal := item.MinGoalScore
|
||
var maxGoal float64
|
||
hasMaxGoal := false
|
||
if item.MaxGoalScore != nil {
|
||
maxGoal = *item.MaxGoalScore
|
||
hasMaxGoal = true
|
||
}
|
||
|
||
// Форматирование Score (+/-)
|
||
scoreFormatted := ""
|
||
if actualScore >= 0 {
|
||
scoreFormatted = fmt.Sprintf("+%.2f", actualScore)
|
||
} else {
|
||
scoreFormatted = fmt.Sprintf("%.2f", actualScore)
|
||
}
|
||
|
||
// Форматирование текста целей
|
||
// Проверяем, что minGoal валиден (не NaN, как в JS коде: !isNaN(minGoal))
|
||
goalText := ""
|
||
if !math.IsNaN(minGoal) {
|
||
if hasMaxGoal && !math.IsNaN(maxGoal) {
|
||
goalText = fmt.Sprintf(" (Цель: %.1f–%.1f)", minGoal, maxGoal)
|
||
} else {
|
||
goalText = fmt.Sprintf(" (Цель: мин. %.1f)", minGoal)
|
||
}
|
||
}
|
||
|
||
// Собираем строку: Проект: +Score (Цели)
|
||
markdownMessage += fmt.Sprintf("*%s*: %s%s\n", projectName, scoreFormatted, goalText)
|
||
}
|
||
|
||
// Выводим итоговый total из корня JSON
|
||
if data.Total != nil {
|
||
markdownMessage += "\n---\n"
|
||
markdownMessage += fmt.Sprintf("*Общее выполнение целей*: %.1f%%", *data.Total)
|
||
}
|
||
|
||
return markdownMessage
|
||
}
|
||
|
||
// sendDailyReport отправляет персональные ежедневные отчеты всем пользователям
|
||
func (a *App) sendDailyReport() error {
|
||
log.Printf("Scheduled task: Sending daily reports")
|
||
|
||
userIDs, err := a.getAllUsersWithTelegram()
|
||
if err != nil {
|
||
return fmt.Errorf("error getting users: %w", err)
|
||
}
|
||
|
||
if len(userIDs) == 0 {
|
||
log.Printf("No users with Telegram connected, skipping daily report")
|
||
return nil
|
||
}
|
||
|
||
for _, userID := range userIDs {
|
||
data, err := a.getWeeklyStatsDataForUser(userID)
|
||
if err != nil {
|
||
log.Printf("Error getting data for user %d: %v", userID, err)
|
||
continue
|
||
}
|
||
|
||
message := a.formatDailyReport(data)
|
||
if message == "" {
|
||
continue
|
||
}
|
||
|
||
if err := a.sendTelegramMessageToUser(userID, message); err != nil {
|
||
log.Printf("Error sending daily report to user %d: %v", userID, err)
|
||
} else {
|
||
log.Printf("Daily report sent to user %d", userID)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// startDailyReportScheduler запускает планировщик для ежедневного отчета
|
||
// каждый день в 23:59 в указанном часовом поясе
|
||
func (a *App) startDailyReportScheduler() {
|
||
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
||
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||
log.Printf("Loading timezone for daily report scheduler: '%s'", timezoneStr)
|
||
|
||
// Загружаем часовой пояс
|
||
loc, err := time.LoadLocation(timezoneStr)
|
||
if err != nil {
|
||
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
|
||
log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'")
|
||
loc = time.UTC
|
||
timezoneStr = "UTC"
|
||
} else {
|
||
log.Printf("Daily report scheduler timezone set to: %s", timezoneStr)
|
||
}
|
||
|
||
// Логируем текущее время в указанном часовом поясе для проверки
|
||
now := time.Now().In(loc)
|
||
log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
|
||
log.Printf("Next daily report will be sent at: 23:59 %s (cron: '59 23 * * *')", timezoneStr)
|
||
|
||
// Создаем планировщик с указанным часовым поясом
|
||
c := cron.New(cron.WithLocation(loc))
|
||
|
||
// Добавляем задачу: каждый день в 23:59
|
||
// Cron выражение: "59 23 * * *" означает: минута=59, час=23, любой день месяца, любой месяц, любой день недели
|
||
_, err = c.AddFunc("59 23 * * *", func() {
|
||
now := time.Now().In(loc)
|
||
log.Printf("Scheduled task: Sending daily report (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
|
||
if err := a.sendDailyReport(); err != nil {
|
||
log.Printf("Error in scheduled daily report: %v", err)
|
||
}
|
||
})
|
||
|
||
if err != nil {
|
||
log.Printf("Error adding cron job for daily report: %v", err)
|
||
return
|
||
}
|
||
|
||
// Запускаем планировщик
|
||
c.Start()
|
||
log.Printf("Daily report scheduler started: every day at 23:59 %s", timezoneStr)
|
||
|
||
// Планировщик будет работать в фоновом режиме
|
||
}
|
||
|
||
// startEndOfDayTaskScheduler запускает планировщик для автовыполнения задач в конце дня
|
||
// каждый день в 23:55 в указанном часовом поясе
|
||
func (a *App) startEndOfDayTaskScheduler() {
|
||
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
||
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||
log.Printf("Loading timezone for end of day task scheduler: '%s'", timezoneStr)
|
||
|
||
// Загружаем часовой пояс
|
||
loc, err := time.LoadLocation(timezoneStr)
|
||
if err != nil {
|
||
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
|
||
log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'")
|
||
loc = time.UTC
|
||
timezoneStr = "UTC"
|
||
} else {
|
||
log.Printf("End of day task scheduler timezone set to: %s", timezoneStr)
|
||
}
|
||
|
||
// Логируем текущее время в указанном часовом поясе для проверки
|
||
now := time.Now().In(loc)
|
||
log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
|
||
log.Printf("Next end of day task execution will be at: 23:55 %s (cron: '55 23 * * *')", timezoneStr)
|
||
|
||
// Создаем планировщик с указанным часовым поясом
|
||
c := cron.New(cron.WithLocation(loc))
|
||
|
||
// Добавляем задачу: каждый день в 23:55
|
||
// Cron выражение: "55 23 * * *" означает: минута=55, час=23, любой день месяца, любой месяц, любой день недели
|
||
_, err = c.AddFunc("55 23 * * *", func() {
|
||
now := time.Now().In(loc)
|
||
log.Printf("Scheduled task: Executing end of day tasks (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
|
||
|
||
// Находим все задачи с auto_complete = true
|
||
rows, err := a.DB.Query(`
|
||
SELECT task_id, user_id, progression_value
|
||
FROM task_drafts
|
||
WHERE auto_complete = TRUE
|
||
`)
|
||
if err != nil {
|
||
log.Printf("Error querying tasks for end of day execution: %v", err)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
tasksToExecute := make([]struct {
|
||
TaskID int
|
||
UserID int
|
||
ProgressionValue *float64
|
||
}, 0)
|
||
|
||
for rows.Next() {
|
||
var taskID, userID int
|
||
var progressionValue sql.NullFloat64
|
||
if err := rows.Scan(&taskID, &userID, &progressionValue); err != nil {
|
||
log.Printf("Error scanning task for end of day execution: %v", err)
|
||
continue
|
||
}
|
||
var progValue *float64
|
||
if progressionValue.Valid {
|
||
progValue = &progressionValue.Float64
|
||
}
|
||
tasksToExecute = append(tasksToExecute, struct {
|
||
TaskID int
|
||
UserID int
|
||
ProgressionValue *float64
|
||
}{TaskID: taskID, UserID: userID, ProgressionValue: progValue})
|
||
}
|
||
|
||
// Для каждой задачи загружаем подзадачи из драфта и выполняем
|
||
for _, taskInfo := range tasksToExecute {
|
||
// Загружаем подзадачи из драфта
|
||
subtaskRows, err := a.DB.Query(`
|
||
SELECT subtask_id
|
||
FROM task_draft_subtasks
|
||
WHERE task_draft_id = (SELECT id FROM task_drafts WHERE task_id = $1)
|
||
`, taskInfo.TaskID)
|
||
|
||
childrenTaskIDs := make([]int, 0)
|
||
if err == nil {
|
||
defer subtaskRows.Close()
|
||
for subtaskRows.Next() {
|
||
var subtaskID int
|
||
if err := subtaskRows.Scan(&subtaskID); err == nil {
|
||
childrenTaskIDs = append(childrenTaskIDs, subtaskID)
|
||
}
|
||
}
|
||
} else if err != sql.ErrNoRows {
|
||
log.Printf("Error loading subtasks for task %d: %v", taskInfo.TaskID, err)
|
||
}
|
||
|
||
// Формируем CompleteTaskRequest из данных драфта
|
||
req := CompleteTaskRequest{
|
||
Value: taskInfo.ProgressionValue,
|
||
ChildrenTaskIDs: childrenTaskIDs,
|
||
}
|
||
|
||
// Вызываем executeTask - она сама удалит драфт перед выполнением
|
||
err = a.executeTask(taskInfo.TaskID, taskInfo.UserID, req)
|
||
if err != nil {
|
||
log.Printf("Error executing task %d at end of day: %v", taskInfo.TaskID, err)
|
||
} else {
|
||
log.Printf("Task %d executed successfully at end of day", taskInfo.TaskID)
|
||
}
|
||
}
|
||
})
|
||
|
||
if err != nil {
|
||
log.Printf("Error adding cron job for end of day tasks: %v", err)
|
||
return
|
||
}
|
||
|
||
// Запускаем планировщик
|
||
c.Start()
|
||
log.Printf("End of day task scheduler started: every day at 23:55 %s", timezoneStr)
|
||
|
||
// Планировщик будет работать в фоновом режиме
|
||
}
|
||
|
||
// readVersion читает версию из файла VERSION
|
||
func readVersion() string {
|
||
// Пробуем разные пути к файлу VERSION
|
||
paths := []string{
|
||
"/app/VERSION", // В Docker контейнере
|
||
"../VERSION", // При запуске из play-life-backend/
|
||
"../../VERSION", // Альтернативный путь
|
||
"VERSION", // Текущая директория
|
||
}
|
||
|
||
for _, path := range paths {
|
||
data, err := os.ReadFile(path)
|
||
if err == nil {
|
||
version := strings.TrimSpace(string(data))
|
||
if version != "" {
|
||
return version
|
||
}
|
||
}
|
||
}
|
||
|
||
return "unknown"
|
||
}
|
||
|
||
func main() {
|
||
// Читаем версию приложения
|
||
version := readVersion()
|
||
log.Printf("========================================")
|
||
log.Printf("Play Life Backend v%s", version)
|
||
log.Printf("========================================")
|
||
|
||
// Загружаем переменные окружения из .env файла (если существует)
|
||
// Сначала пробуем загрузить из корня проекта, затем из текущей директории
|
||
// Игнорируем ошибку, если файл не найден
|
||
godotenv.Load("../.env") // Пробуем корневой .env
|
||
godotenv.Load(".env") // Пробуем локальный .env
|
||
|
||
dbHost := getEnv("DB_HOST", "localhost")
|
||
dbPort := getEnv("DB_PORT", "5432")
|
||
dbUser := getEnv("DB_USER", "playeng")
|
||
dbPassword := getEnv("DB_PASSWORD", "playeng")
|
||
dbName := getEnv("DB_NAME", "playeng")
|
||
|
||
// Логируем параметры подключения к БД (без пароля)
|
||
log.Printf("Database connection parameters: host=%s port=%s user=%s dbname=%s", dbHost, dbPort, dbUser, dbName)
|
||
|
||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||
dbHost, dbPort, dbUser, dbPassword, dbName)
|
||
|
||
var db *sql.DB
|
||
var err error
|
||
|
||
// Retry connection
|
||
for i := 0; i < 10; i++ {
|
||
db, err = sql.Open("postgres", dsn)
|
||
if err == nil {
|
||
err = db.Ping()
|
||
if err == nil {
|
||
break
|
||
}
|
||
}
|
||
if i < 9 {
|
||
time.Sleep(2 * time.Second)
|
||
}
|
||
}
|
||
|
||
if err != nil {
|
||
log.Fatal("Failed to connect to database:", err)
|
||
}
|
||
log.Printf("Successfully connected to database: %s@%s:%s/%s", dbUser, dbHost, dbPort, dbName)
|
||
defer db.Close()
|
||
|
||
// Telegram бот теперь загружается из БД при необходимости
|
||
// Webhook будет настроен автоматически при сохранении bot token через UI
|
||
|
||
// JWT secret from env or generate random
|
||
jwtSecret := getEnv("JWT_SECRET", "")
|
||
if jwtSecret == "" {
|
||
// Generate random secret if not provided (not recommended for production)
|
||
b := make([]byte, 32)
|
||
rand.Read(b)
|
||
jwtSecret = base64.StdEncoding.EncodeToString(b)
|
||
log.Printf("WARNING: JWT_SECRET not set, using randomly generated secret. Set JWT_SECRET env var for production.")
|
||
}
|
||
|
||
app := &App{
|
||
DB: db,
|
||
lastWebhookTime: make(map[int]time.Time),
|
||
telegramBot: nil,
|
||
telegramBotUsername: "",
|
||
jwtSecret: []byte(jwtSecret),
|
||
}
|
||
|
||
// Инициализация Telegram бота из .env
|
||
telegramBotToken := getEnv("TELEGRAM_BOT_TOKEN", "")
|
||
if telegramBotToken != "" {
|
||
bot, err := tgbotapi.NewBotAPI(telegramBotToken)
|
||
if err != nil {
|
||
log.Printf("WARNING: Failed to initialize Telegram bot: %v", err)
|
||
} else {
|
||
app.telegramBot = bot
|
||
log.Printf("Telegram bot initialized successfully")
|
||
|
||
// Получаем username бота через getMe
|
||
botInfo, err := bot.GetMe()
|
||
if err != nil {
|
||
log.Printf("WARNING: Failed to get bot info via getMe(): %v", err)
|
||
} else {
|
||
app.telegramBotUsername = botInfo.UserName
|
||
log.Printf("Telegram bot username: @%s", app.telegramBotUsername)
|
||
}
|
||
|
||
// Настраиваем webhook для единого бота
|
||
webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||
if webhookBaseURL != "" {
|
||
webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram"
|
||
log.Printf("Setting up Telegram webhook: URL=%s", webhookURL)
|
||
if err := setupTelegramWebhook(telegramBotToken, webhookURL); err != nil {
|
||
log.Printf("WARNING: Failed to setup Telegram webhook: %v", err)
|
||
} else {
|
||
log.Printf("SUCCESS: Telegram webhook configured: %s", webhookURL)
|
||
}
|
||
} else {
|
||
log.Printf("WEBHOOK_BASE_URL not set. Webhook will not be configured.")
|
||
}
|
||
}
|
||
} else {
|
||
log.Printf("WARNING: TELEGRAM_BOT_TOKEN not set in environment")
|
||
}
|
||
|
||
// Apply database migrations
|
||
if err := app.runMigrations(); err != nil {
|
||
log.Fatal("Failed to apply database migrations:", err)
|
||
}
|
||
log.Println("Database migrations applied successfully")
|
||
|
||
// Запускаем планировщик для автоматической фиксации целей на неделю
|
||
app.startWeeklyGoalsScheduler()
|
||
|
||
// Запускаем планировщик для ежедневного отчета в 23:59
|
||
app.startDailyReportScheduler()
|
||
|
||
// Запускаем планировщик для автовыполнения задач в конце дня в 23:55
|
||
app.startEndOfDayTaskScheduler()
|
||
|
||
r := mux.NewRouter()
|
||
|
||
// Public auth routes (no authentication required)
|
||
r.HandleFunc("/api/auth/register", app.registerHandler).Methods("POST", "OPTIONS")
|
||
r.HandleFunc("/api/auth/login", app.loginHandler).Methods("POST", "OPTIONS")
|
||
r.HandleFunc("/api/auth/refresh", app.refreshTokenHandler).Methods("POST", "OPTIONS")
|
||
|
||
// Webhooks - no auth (external services)
|
||
r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS")
|
||
r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
|
||
r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS")
|
||
|
||
// Admin pages (HTML is public, but API calls require auth)
|
||
// Note: We serve HTML without auth check, but JavaScript will check auth and API calls are protected
|
||
r.HandleFunc("/admin", app.adminHandler).Methods("GET")
|
||
r.HandleFunc("/admin.html", app.adminHandler).Methods("GET")
|
||
|
||
// Admin API routes (require authentication and admin privileges)
|
||
adminAPIRoutes := r.PathPrefix("/").Subrouter()
|
||
adminAPIRoutes.Use(app.authMiddleware)
|
||
adminAPIRoutes.Use(app.adminMiddleware)
|
||
adminAPIRoutes.HandleFunc("/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")
|
||
|
||
// Static files handler для uploads (public, no auth required) - ДО protected!
|
||
// Backend работает из /app/backend/, но uploads находится в /app/uploads/
|
||
r.HandleFunc("/uploads/{path:.*}", func(w http.ResponseWriter, r *http.Request) {
|
||
vars := mux.Vars(r)
|
||
path := vars["path"]
|
||
filePath := filepath.Join("/app/uploads", path)
|
||
|
||
// Проверяем, что файл существует
|
||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
// Отдаём файл
|
||
http.ServeFile(w, r, filePath)
|
||
}).Methods("GET")
|
||
|
||
// Protected routes (require authentication)
|
||
protected := r.PathPrefix("/").Subrouter()
|
||
protected.Use(app.authMiddleware)
|
||
|
||
// Auth routes that need authentication
|
||
protected.HandleFunc("/api/auth/logout", app.logoutHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/auth/me", app.getMeHandler).Methods("GET", "OPTIONS")
|
||
|
||
// Words & dictionaries
|
||
protected.HandleFunc("/api/words", app.getWordsHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/words", app.addWordsHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/test/words", app.getTestWordsHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/test/progress", app.updateTestProgressHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/dictionaries", app.getDictionariesHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/dictionaries", app.addDictionaryHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/dictionaries/{id}", app.updateDictionaryHandler).Methods("PUT", "OPTIONS")
|
||
protected.HandleFunc("/api/dictionaries/{id}", app.deleteDictionaryHandler).Methods("DELETE", "OPTIONS")
|
||
|
||
// Configs
|
||
protected.HandleFunc("/api/configs", app.getConfigsHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/configs", app.addConfigHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/configs/{id}", app.updateConfigHandler).Methods("PUT", "OPTIONS")
|
||
protected.HandleFunc("/api/configs/{id}", app.deleteConfigHandler).Methods("DELETE", "OPTIONS")
|
||
protected.HandleFunc("/api/configs/{id}/dictionaries", app.getConfigDictionariesHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/test-configs-and-dictionaries", app.getTestConfigsAndDictionariesHandler).Methods("GET", "OPTIONS")
|
||
|
||
// Projects & stats
|
||
protected.HandleFunc("/api/weekly-stats", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/playlife-feed", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS")
|
||
// Note: /message/post, /weekly_goals/setup, /daily-report/trigger moved to adminAPIRoutes
|
||
protected.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/project/color", app.setProjectColorHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/project/create", app.createProjectHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/today-entries", app.getTodayEntriesHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/entries/{id}", app.deleteEntryHandler).Methods("DELETE", "OPTIONS")
|
||
|
||
// Integrations
|
||
protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "OPTIONS")
|
||
|
||
// Todoist OAuth endpoints
|
||
protected.HandleFunc("/api/integrations/todoist/oauth/connect", app.todoistOAuthConnectHandler).Methods("GET")
|
||
r.HandleFunc("/api/integrations/todoist/oauth/callback", app.todoistOAuthCallbackHandler).Methods("GET") // Публичный!
|
||
protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS")
|
||
|
||
// Tasks
|
||
protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS")
|
||
// Специфичные роуты должны быть ПЕРЕД общим роутом /api/tasks/{id}
|
||
protected.HandleFunc("/api/tasks/{id}/complete", app.completeTaskHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/tasks/{id}/complete-and-delete", app.completeAndDeleteTaskHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/tasks/{id}/draft", app.saveTaskDraftHandler).Methods("PUT", "OPTIONS")
|
||
protected.HandleFunc("/api/tasks/{id}/complete-at-end-of-day", app.completeTaskAtEndOfDayHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/tasks/{id}", app.getTaskDetailHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/tasks/{id}", app.updateTaskHandler).Methods("PUT", "OPTIONS")
|
||
protected.HandleFunc("/api/tasks/{id}", app.deleteTaskHandler).Methods("DELETE", "OPTIONS")
|
||
|
||
// Wishlist
|
||
protected.HandleFunc("/api/wishlist", app.getWishlistHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/completed", app.getWishlistCompletedHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/proxy-image", app.proxyImageHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/calculate-weeks", app.calculateWeeksHandler).Methods("POST", "OPTIONS")
|
||
|
||
// Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!)
|
||
protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards", app.createBoardHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards/{id}", app.getBoardHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards/{id}", app.updateBoardHandler).Methods("PUT", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards/{id}", app.deleteBoardHandler).Methods("DELETE", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards/{id}/regenerate-invite", app.regenerateBoardInviteHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards/{id}/members", app.getBoardMembersHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards/{id}/members/{userId}", app.removeBoardMemberHandler).Methods("DELETE", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards/{id}/leave", app.leaveBoardHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.getBoardItemsHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.createBoardItemHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/boards/{boardId}/completed", app.getBoardCompletedHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/invite/{token}", app.getBoardInviteInfoHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/invite/{token}/join", app.joinBoardHandler).Methods("POST", "OPTIONS")
|
||
|
||
// Wishlist items (после boards, чтобы {id} не перехватывал "boards")
|
||
protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/{id}", app.deleteWishlistHandler).Methods("DELETE", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/{id}/image", app.uploadWishlistImageHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/{id}/image", app.deleteWishlistImageHandler).Methods("DELETE", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/{id}/complete", app.completeWishlistHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/{id}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS")
|
||
protected.HandleFunc("/api/wishlist/{id}/copy", app.copyWishlistHandler).Methods("POST", "OPTIONS")
|
||
|
||
// Admin operations
|
||
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
|
||
|
||
port := getEnv("PORT", "8080")
|
||
log.Printf("Server starting on port %s", port)
|
||
log.Printf("Registered public routes: /api/auth/register, /api/auth/login, /api/auth/refresh, webhooks")
|
||
log.Printf("All other routes require authentication via Bearer token")
|
||
log.Printf("Admin panel available at: http://localhost:%s/admin.html", port)
|
||
log.Fatal(http.ListenAndServe(":"+port, r))
|
||
}
|
||
|
||
func getEnv(key, defaultValue string) string {
|
||
if value := os.Getenv(key); value != "" {
|
||
return value
|
||
}
|
||
return defaultValue
|
||
}
|
||
|
||
// getMapKeys возвращает список ключей из map
|
||
func getMapKeys(m map[string]interface{}) []string {
|
||
keys := make([]string, 0, len(m))
|
||
for k := range m {
|
||
keys = append(keys, k)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// setupTelegramWebhook настраивает webhook для Telegram бота
|
||
func setupTelegramWebhook(botToken, webhookURL string) error {
|
||
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken)
|
||
log.Printf("Setting up Telegram webhook: apiURL=%s, webhookURL=%s", apiURL, webhookURL)
|
||
|
||
payload := map[string]string{
|
||
"url": webhookURL,
|
||
}
|
||
|
||
jsonData, err := json.Marshal(payload)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
||
}
|
||
|
||
// Создаем HTTP клиент с таймаутом
|
||
client := &http.Client{
|
||
Timeout: 10 * time.Second,
|
||
}
|
||
|
||
resp, err := client.Post(apiURL, "application/json", bytes.NewBuffer(jsonData))
|
||
if err != nil {
|
||
log.Printf("ERROR: Failed to send webhook setup request: %v", err)
|
||
return fmt.Errorf("failed to send webhook setup request: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
bodyBytes, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to read response body: %w", err)
|
||
}
|
||
log.Printf("Telegram API response: status=%d, body=%s", resp.StatusCode, string(bodyBytes))
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("telegram API returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||
}
|
||
|
||
// Декодируем из уже прочитанных байтов
|
||
var result map[string]interface{}
|
||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||
return fmt.Errorf("failed to decode response: %w", err)
|
||
}
|
||
|
||
if ok, _ := result["ok"].(bool); !ok {
|
||
description, _ := result["description"].(string)
|
||
return fmt.Errorf("telegram API returned error: %s", description)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// Вспомогательные функции для расчетов
|
||
func min(a, b float64) float64 {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func max(a, b float64) float64 {
|
||
if a > b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func roundToTwoDecimals(val float64) float64 {
|
||
return float64(int(val*100+0.5)) / 100.0
|
||
}
|
||
|
||
func roundToFourDecimals(val float64) float64 {
|
||
return float64(int(val*10000+0.5)) / 10000.0
|
||
}
|
||
|
||
// calculateGroupsProgress вычисляет проценты выполнения для каждой группы приоритетов
|
||
// groups - карта приоритетов к спискам calculatedScore проектов
|
||
// Возвращает структуру GroupsProgress с процентами для каждой группы
|
||
// Если какая-то группа отсутствует, она считается как 100%
|
||
func calculateGroupsProgress(groups map[int][]float64) GroupsProgress {
|
||
// Всего есть 3 группы: приоритет 1, приоритет 2, приоритет 0
|
||
// Вычисляем среднее для каждой группы, если она есть
|
||
// Если группы нет, считаем её как 100%
|
||
|
||
result := GroupsProgress{}
|
||
|
||
// Обрабатываем все 3 возможных приоритета
|
||
priorities := []int{1, 2, 0}
|
||
|
||
for _, priorityVal := range priorities {
|
||
scores, exists := groups[priorityVal]
|
||
|
||
var avg float64
|
||
if !exists || len(scores) == 0 {
|
||
// Если группы нет, считаем как 100%
|
||
avg = 100.0
|
||
} else {
|
||
// Вычисляем среднее для группы
|
||
// Для всех приоритетов: если 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 - обычное среднее с корректировкой
|
||
if priorityVal == 1 || priorityVal == 2 {
|
||
sum := 0.0
|
||
for _, score := range scores {
|
||
sum += adjustScore(score)
|
||
}
|
||
avg = sum / float64(len(scores))
|
||
} else {
|
||
// Для проектов без приоритета (priorityVal == 0) - специальная формула с корректировкой
|
||
projectCount := float64(len(scores))
|
||
multiplier := 100.0 / (projectCount * 0.8)
|
||
|
||
sum := 0.0
|
||
for _, score := range scores {
|
||
// Применяем корректировку перед использованием в формуле
|
||
adjustedScore := adjustScore(score)
|
||
// score уже в процентах (например, 80.0), переводим в долю (0.8)
|
||
scoreAsDecimal := adjustedScore / 100.0
|
||
sum += scoreAsDecimal * multiplier
|
||
}
|
||
|
||
avg = math.Min(120.0, sum)
|
||
}
|
||
}
|
||
|
||
// Сохраняем результат в соответствующее поле
|
||
avgRounded := roundToFourDecimals(avg)
|
||
switch priorityVal {
|
||
case 1:
|
||
result.Group1 = &avgRounded
|
||
case 2:
|
||
result.Group2 = &avgRounded
|
||
case 0:
|
||
result.Group0 = &avgRounded
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// calculateOverallProgress вычисляет общий процент выполнения на основе процентов групп
|
||
// groupsProgress - структура с процентами для каждой группы приоритетов
|
||
// Возвращает указатель на float64 с общим процентом выполнения
|
||
// Всегда вычисляет среднее всех трех групп (даже если какая-то группа отсутствует, она считается как 100%)
|
||
func calculateOverallProgress(groupsProgress GroupsProgress) *float64 {
|
||
// Находим среднее между всеми тремя группами
|
||
// Если какая-то группа отсутствует (nil), считаем её как 100%
|
||
|
||
var group1Val, group2Val, group0Val float64
|
||
|
||
if groupsProgress.Group1 != nil {
|
||
group1Val = *groupsProgress.Group1
|
||
} else {
|
||
group1Val = 100.0
|
||
}
|
||
|
||
if groupsProgress.Group2 != nil {
|
||
group2Val = *groupsProgress.Group2
|
||
} else {
|
||
group2Val = 100.0
|
||
}
|
||
|
||
if groupsProgress.Group0 != nil {
|
||
group0Val = *groupsProgress.Group0
|
||
} else {
|
||
group0Val = 100.0
|
||
}
|
||
|
||
overallProgress := (group1Val + group2Val + group0Val) / 3.0 // Всегда делим на 3, так как групп всегда 3
|
||
overallProgressRounded := roundToFourDecimals(overallProgress)
|
||
total := &overallProgressRounded
|
||
|
||
return total
|
||
}
|
||
|
||
// TelegramIntegration представляет запись из таблицы telegram_integrations
|
||
type TelegramIntegration struct {
|
||
ID int `json:"id"`
|
||
UserID int `json:"user_id"`
|
||
TelegramUserID *int64 `json:"telegram_user_id,omitempty"`
|
||
ChatID *string `json:"chat_id,omitempty"`
|
||
StartToken *string `json:"start_token,omitempty"`
|
||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||
}
|
||
|
||
// TodoistIntegration представляет запись из таблицы todoist_integrations
|
||
type TodoistIntegration struct {
|
||
ID int `json:"id"`
|
||
UserID int `json:"user_id"`
|
||
TodoistUserID *int64 `json:"todoist_user_id,omitempty"`
|
||
TodoistEmail *string `json:"todoist_email,omitempty"`
|
||
AccessToken *string `json:"-"` // Не отдавать в JSON!
|
||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||
}
|
||
|
||
// getTelegramIntegration получает telegram интеграцию из БД
|
||
// getTelegramIntegrationForUser gets telegram integration for specific user
|
||
func (a *App) getTelegramIntegrationForUser(userID int) (*TelegramIntegration, error) {
|
||
var integration TelegramIntegration
|
||
var telegramUserID sql.NullInt64
|
||
var chatID, startToken sql.NullString
|
||
var createdAt, updatedAt sql.NullTime
|
||
|
||
err := a.DB.QueryRow(`
|
||
SELECT id, user_id, telegram_user_id, chat_id, start_token, created_at, updated_at
|
||
FROM telegram_integrations
|
||
WHERE user_id = $1
|
||
LIMIT 1
|
||
`, userID).Scan(
|
||
&integration.ID,
|
||
&integration.UserID,
|
||
&telegramUserID,
|
||
&chatID,
|
||
&startToken,
|
||
&createdAt,
|
||
&updatedAt,
|
||
)
|
||
|
||
if err == sql.ErrNoRows {
|
||
// Создаем новую запись с start_token
|
||
startTokenValue, err := generateWebhookToken()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to generate start token: %w", err)
|
||
}
|
||
|
||
err = a.DB.QueryRow(`
|
||
INSERT INTO telegram_integrations (user_id, start_token)
|
||
VALUES ($1, $2)
|
||
RETURNING id, user_id, telegram_user_id, chat_id, start_token, created_at, updated_at
|
||
`, userID, startTokenValue).Scan(
|
||
&integration.ID,
|
||
&integration.UserID,
|
||
&telegramUserID,
|
||
&chatID,
|
||
&startToken,
|
||
&createdAt,
|
||
&updatedAt,
|
||
)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to create telegram integration: %w", err)
|
||
}
|
||
startToken = sql.NullString{String: startTokenValue, Valid: true}
|
||
} else if err != nil {
|
||
return nil, fmt.Errorf("failed to get telegram integration: %w", err)
|
||
}
|
||
|
||
// Заполняем указатели
|
||
if telegramUserID.Valid {
|
||
integration.TelegramUserID = &telegramUserID.Int64
|
||
}
|
||
if chatID.Valid {
|
||
integration.ChatID = &chatID.String
|
||
}
|
||
if startToken.Valid {
|
||
integration.StartToken = &startToken.String
|
||
}
|
||
if createdAt.Valid {
|
||
integration.CreatedAt = &createdAt.Time
|
||
}
|
||
if updatedAt.Valid {
|
||
integration.UpdatedAt = &updatedAt.Time
|
||
}
|
||
|
||
return &integration, nil
|
||
}
|
||
|
||
// sendTelegramMessageToChat - отправляет сообщение в конкретный чат по chat_id
|
||
func (a *App) sendTelegramMessageToChat(chatID int64, text string) error {
|
||
if a.telegramBot == nil {
|
||
return fmt.Errorf("telegram bot not initialized")
|
||
}
|
||
|
||
telegramText := regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "*$1*")
|
||
msg := tgbotapi.NewMessage(chatID, telegramText)
|
||
msg.ParseMode = "Markdown"
|
||
|
||
_, err := a.telegramBot.Send(msg)
|
||
if err != nil {
|
||
// Проверяем, не заблокирован ли бот
|
||
if strings.Contains(err.Error(), "blocked") ||
|
||
strings.Contains(err.Error(), "chat not found") ||
|
||
strings.Contains(err.Error(), "bot was blocked") {
|
||
// Пользователь заблокировал бота - очищаем данные
|
||
chatIDStr := strconv.FormatInt(chatID, 10)
|
||
a.DB.Exec(`
|
||
UPDATE telegram_integrations
|
||
SET telegram_user_id = NULL, chat_id = NULL, updated_at = CURRENT_TIMESTAMP
|
||
WHERE chat_id = $1
|
||
`, chatIDStr)
|
||
log.Printf("User blocked bot, cleared integration for chat_id=%d", chatID)
|
||
}
|
||
return err
|
||
}
|
||
|
||
log.Printf("Message sent to chat_id=%d", chatID)
|
||
return nil
|
||
}
|
||
|
||
// sendTelegramMessageToUser - отправляет сообщение пользователю по user_id
|
||
func (a *App) sendTelegramMessageToUser(userID int, text string) error {
|
||
var chatID sql.NullString
|
||
err := a.DB.QueryRow(`
|
||
SELECT chat_id FROM telegram_integrations
|
||
WHERE user_id = $1 AND chat_id IS NOT NULL
|
||
`, userID).Scan(&chatID)
|
||
|
||
if err == sql.ErrNoRows || !chatID.Valid {
|
||
return fmt.Errorf("telegram not connected for user %d", userID)
|
||
}
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
chatIDInt, err := strconv.ParseInt(chatID.String, 10, 64)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid chat_id format: %w", err)
|
||
}
|
||
|
||
return a.sendTelegramMessageToChat(chatIDInt, text)
|
||
}
|
||
|
||
// getAllUsersWithTelegram - получает список всех user_id с подключенным Telegram
|
||
func (a *App) getAllUsersWithTelegram() ([]int, error) {
|
||
rows, err := a.DB.Query(`
|
||
SELECT user_id FROM telegram_integrations
|
||
WHERE chat_id IS NOT NULL AND telegram_user_id IS NOT NULL
|
||
`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var userIDs []int
|
||
for rows.Next() {
|
||
var userID int
|
||
if err := rows.Scan(&userID); err == nil {
|
||
userIDs = append(userIDs, userID)
|
||
}
|
||
}
|
||
return userIDs, nil
|
||
}
|
||
|
||
// utf16OffsetToUTF8 конвертирует UTF-16 offset в UTF-8 byte offset
|
||
func utf16OffsetToUTF8(text string, utf16Offset int) int {
|
||
utf16Runes := utf16.Encode([]rune(text))
|
||
if utf16Offset >= len(utf16Runes) {
|
||
return len(text)
|
||
}
|
||
|
||
// Конвертируем UTF-16 кодовые единицы обратно в UTF-8 байты
|
||
runes := utf16.Decode(utf16Runes[:utf16Offset])
|
||
return len(string(runes))
|
||
}
|
||
|
||
// utf16LengthToUTF8 конвертирует UTF-16 length в UTF-8 byte length
|
||
func utf16LengthToUTF8(text string, utf16Offset, utf16Length int) int {
|
||
utf16Runes := utf16.Encode([]rune(text))
|
||
if utf16Offset+utf16Length > len(utf16Runes) {
|
||
utf16Length = len(utf16Runes) - utf16Offset
|
||
}
|
||
if utf16Length <= 0 {
|
||
return 0
|
||
}
|
||
|
||
// Конвертируем UTF-16 кодовые единицы в UTF-8 байты
|
||
startRunes := utf16.Decode(utf16Runes[:utf16Offset])
|
||
endRunes := utf16.Decode(utf16Runes[:utf16Offset+utf16Length])
|
||
|
||
startBytes := len(string(startRunes))
|
||
endBytes := len(string(endRunes))
|
||
|
||
return endBytes - startBytes
|
||
}
|
||
|
||
// processTelegramMessage обрабатывает сообщение из Telegram с использованием entities
|
||
// Логика отличается от processMessage: использует entities для определения жирного текста
|
||
// и не отправляет сообщение обратно в Telegram
|
||
// userID может быть nil, если пользователь не определен
|
||
func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity, userID *int) (*ProcessedEntry, error) {
|
||
fullText = strings.TrimSpace(fullText)
|
||
|
||
// Регулярное выражение: project+/-score (без **)
|
||
scoreRegex := regexp.MustCompile(`^([а-яА-ЯёЁ\w]+)([+-])(\d+(?:\.\d+)?)$`)
|
||
|
||
// Массив для хранения извлеченных элементов {project, score}
|
||
scoreNodes := make([]ProcessedNode, 0)
|
||
workingText := fullText
|
||
placeholderIndex := 0
|
||
|
||
// Находим все элементы, выделенные жирным шрифтом
|
||
boldEntities := make([]TelegramEntity, 0)
|
||
for _, entity := range entities {
|
||
if entity.Type == "bold" {
|
||
boldEntities = append(boldEntities, entity)
|
||
}
|
||
}
|
||
|
||
// Сортируем в ПРЯМОМ порядке (по offset), чтобы гарантировать, что ${0} соответствует первому в тексте
|
||
sort.Slice(boldEntities, func(i, j int) bool {
|
||
return boldEntities[i].Offset < boldEntities[j].Offset
|
||
})
|
||
|
||
// Массив для хранения данных, которые будут использоваться для замены в обратном порядке
|
||
type ReplacementData struct {
|
||
Start int
|
||
Length int
|
||
Placeholder string
|
||
}
|
||
replacementData := make([]ReplacementData, 0)
|
||
|
||
for _, entity := range boldEntities {
|
||
// Telegram использует UTF-16 для offset и length, конвертируем в UTF-8 байты
|
||
start := utf16OffsetToUTF8(fullText, entity.Offset)
|
||
length := utf16LengthToUTF8(fullText, entity.Offset, entity.Length)
|
||
|
||
// Извлекаем чистый жирный текст
|
||
if start+length > len(fullText) {
|
||
continue // Пропускаем некорректные entities
|
||
}
|
||
boldText := strings.TrimSpace(fullText[start : start+length])
|
||
|
||
// Проверяем соответствие формату
|
||
match := scoreRegex.FindStringSubmatch(boldText)
|
||
|
||
if match != nil && len(match) == 4 {
|
||
// Создаем элемент node
|
||
project := match[1]
|
||
sign := match[2]
|
||
rawScore, err := strconv.ParseFloat(match[3], 64)
|
||
if err != nil {
|
||
log.Printf("Error parsing score: %v", err)
|
||
continue
|
||
}
|
||
score := rawScore
|
||
if sign == "-" {
|
||
score = -rawScore
|
||
}
|
||
|
||
// Добавляем в массив nodes (по порядку)
|
||
scoreNodes = append(scoreNodes, ProcessedNode{
|
||
Project: project,
|
||
Score: score,
|
||
})
|
||
|
||
// Создаем данные для замены
|
||
replacementData = append(replacementData, ReplacementData{
|
||
Start: start,
|
||
Length: length,
|
||
Placeholder: fmt.Sprintf("${%d}", placeholderIndex),
|
||
})
|
||
|
||
placeholderIndex++
|
||
}
|
||
}
|
||
|
||
// Теперь выполняем замены в ОБРАТНОМ порядке, чтобы offset не "смещались"
|
||
sort.Slice(replacementData, func(i, j int) bool {
|
||
return replacementData[i].Start > replacementData[j].Start
|
||
})
|
||
|
||
for _, item := range replacementData {
|
||
// Заменяем сегмент в workingText, используя оригинальные offset и length
|
||
if item.Start+item.Length <= len(workingText) {
|
||
workingText = workingText[:item.Start] + item.Placeholder + workingText[item.Start+item.Length:]
|
||
}
|
||
}
|
||
|
||
// Удаляем пустые строки и лишние пробелы
|
||
lines := strings.Split(workingText, "\n")
|
||
cleanedLines := make([]string, 0)
|
||
for _, line := range lines {
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed != "" {
|
||
cleanedLines = append(cleanedLines, trimmed)
|
||
}
|
||
}
|
||
processedText := strings.Join(cleanedLines, "\n")
|
||
|
||
// Используем текущее время в формате ISO 8601 (UTC)
|
||
createdDate := time.Now().UTC().Format(time.RFC3339)
|
||
|
||
// Вставляем данные в БД только если есть nodes
|
||
if len(scoreNodes) > 0 {
|
||
err := a.insertMessageData(processedText, createdDate, scoreNodes, userID)
|
||
if err != nil {
|
||
log.Printf("Error inserting message data: %v", err)
|
||
return nil, fmt.Errorf("error inserting data: %w", err)
|
||
}
|
||
} else {
|
||
// Если nodes нет, используем исходный текст для processedText
|
||
processedText = fullText
|
||
log.Printf("No nodes found in Telegram message, message will not be saved to database")
|
||
}
|
||
|
||
// Формируем ответ
|
||
response := &ProcessedEntry{
|
||
Text: processedText,
|
||
CreatedDate: createdDate,
|
||
Nodes: scoreNodes,
|
||
Raw: fullText,
|
||
Markdown: fullText, // Для Telegram markdown не нужен
|
||
}
|
||
|
||
// НЕ отправляем сообщение обратно в Telegram (в отличие от processMessage)
|
||
|
||
return response, nil
|
||
}
|
||
|
||
// processMessage обрабатывает текст сообщения: парсит ноды, сохраняет в БД и отправляет в Telegram
|
||
func (a *App) processMessage(rawText string, userID *int) (*ProcessedEntry, error) {
|
||
return a.processMessageInternal(rawText, true, userID)
|
||
}
|
||
|
||
// processMessageWithoutTelegram обрабатывает текст сообщения: парсит ноды, сохраняет в БД, но НЕ отправляет в Telegram
|
||
func (a *App) processMessageWithoutTelegram(rawText string, userID *int) (*ProcessedEntry, error) {
|
||
return a.processMessageInternal(rawText, false, userID)
|
||
}
|
||
|
||
// processMessageInternal - внутренняя функция обработки сообщения
|
||
// sendToTelegram определяет, нужно ли отправлять сообщение в Telegram
|
||
func (a *App) processMessageInternal(rawText string, sendToTelegram bool, userID *int) (*ProcessedEntry, error) {
|
||
rawText = strings.TrimSpace(rawText)
|
||
|
||
// Регулярное выражение для поиска **[Project][+| -][Score]**
|
||
regex := regexp.MustCompile(`\*\*(.+?)([+-])([\d.]+)\*\*`)
|
||
|
||
nodes := make([]ProcessedNode, 0)
|
||
nodeCounter := 0
|
||
|
||
// Ищем все node и заменяем их в тексте на плейсхолдеры ${0}, ${1} и т.д.
|
||
processedText := regex.ReplaceAllStringFunc(rawText, func(fullMatch string) string {
|
||
matches := regex.FindStringSubmatch(fullMatch)
|
||
if len(matches) != 4 {
|
||
return fullMatch
|
||
}
|
||
|
||
projectName := strings.TrimSpace(matches[1])
|
||
sign := matches[2]
|
||
scoreString := matches[3]
|
||
|
||
score, err := strconv.ParseFloat(scoreString, 64)
|
||
if err != nil {
|
||
log.Printf("Error parsing score: %v", err)
|
||
return fullMatch
|
||
}
|
||
|
||
if sign == "-" {
|
||
score = -score
|
||
}
|
||
|
||
// Добавляем данные в массив nodes
|
||
nodes = append(nodes, ProcessedNode{
|
||
Project: projectName,
|
||
Score: score,
|
||
})
|
||
|
||
placeholder := fmt.Sprintf("${%d}", nodeCounter)
|
||
nodeCounter++
|
||
return placeholder
|
||
})
|
||
|
||
// Удаляем пустые строки и лишние пробелы
|
||
lines := strings.Split(processedText, "\n")
|
||
cleanedLines := make([]string, 0)
|
||
for _, line := range lines {
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed != "" {
|
||
cleanedLines = append(cleanedLines, trimmed)
|
||
}
|
||
}
|
||
processedText = strings.Join(cleanedLines, "\n")
|
||
|
||
// Формируем Markdown (Legacy) контент: заменяем ** на *
|
||
markdownText := strings.ReplaceAll(rawText, "**", "*")
|
||
|
||
// Используем текущее время
|
||
createdDate := time.Now().UTC().Format(time.RFC3339)
|
||
|
||
// Вставляем данные в БД только если есть nodes
|
||
if len(nodes) > 0 {
|
||
err := a.insertMessageData(processedText, createdDate, nodes, userID)
|
||
if err != nil {
|
||
log.Printf("Error inserting message data: %v", err)
|
||
return nil, fmt.Errorf("error inserting data: %w", err)
|
||
}
|
||
} else {
|
||
// Если nodes нет, используем исходный текст для processedText
|
||
processedText = rawText
|
||
if sendToTelegram {
|
||
log.Printf("No nodes found in text, message will be sent to Telegram but not saved to database")
|
||
} else {
|
||
log.Printf("No nodes found in text, message will be ignored (not saved to database and not sent to Telegram)")
|
||
}
|
||
}
|
||
|
||
// Формируем ответ
|
||
response := &ProcessedEntry{
|
||
Text: processedText,
|
||
CreatedDate: createdDate,
|
||
Nodes: nodes,
|
||
Raw: rawText,
|
||
Markdown: markdownText,
|
||
}
|
||
|
||
// Отправляем дублирующее сообщение в Telegram только если указано
|
||
if sendToTelegram && userID != nil {
|
||
if err := a.sendTelegramMessageToUser(*userID, rawText); err != nil {
|
||
log.Printf("Error sending Telegram message: %v", err)
|
||
}
|
||
}
|
||
|
||
return response, nil
|
||
}
|
||
|
||
func (a *App) messagePostHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
// Get user ID from context (may be nil for webhook)
|
||
var userIDPtr *int
|
||
if userID, ok := getUserIDFromContext(r); ok {
|
||
userIDPtr = &userID
|
||
}
|
||
|
||
// Парсим входящий запрос - может быть как {body: {text: ...}}, так и {text: ...}
|
||
var rawReq map[string]interface{}
|
||
if err := json.NewDecoder(r.Body).Decode(&rawReq); err != nil {
|
||
log.Printf("Error decoding message post request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Извлекаем text из разных возможных структур
|
||
var rawText string
|
||
if body, ok := rawReq["body"].(map[string]interface{}); ok {
|
||
if text, ok := body["text"].(string); ok {
|
||
rawText = text
|
||
}
|
||
}
|
||
|
||
// Если не нашли в body, пробуем напрямую
|
||
if rawText == "" {
|
||
if text, ok := rawReq["text"].(string); ok {
|
||
rawText = text
|
||
}
|
||
}
|
||
|
||
// Проверка на наличие нужного поля
|
||
if rawText == "" {
|
||
sendErrorWithCORS(w, "Missing 'text' field in body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Обрабатываем сообщение
|
||
response, err := a.processMessage(rawText, userIDPtr)
|
||
if err != nil {
|
||
log.Printf("Error processing message: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
func (a *App) insertMessageData(entryText string, createdDate string, nodes []ProcessedNode, userID *int) error {
|
||
// Начинаем транзакцию
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
return fmt.Errorf("failed to begin transaction: %w", err)
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// 1. UPSERT проектов
|
||
projectNames := make(map[string]bool)
|
||
for _, node := range nodes {
|
||
projectNames[node.Project] = true
|
||
}
|
||
|
||
// Вставляем проекты
|
||
for projectName := range projectNames {
|
||
if userID != nil {
|
||
// Используем более универсальный подход: проверяем существование и вставляем/обновляем
|
||
var existingID int
|
||
err := tx.QueryRow(`
|
||
SELECT id FROM projects
|
||
WHERE name = $1 AND user_id = $2 AND deleted = FALSE
|
||
`, projectName, *userID).Scan(&existingID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
// Проект не существует, создаем новый
|
||
randomColor := generateRandomProjectColor()
|
||
_, err = tx.Exec(`
|
||
INSERT INTO projects (name, deleted, user_id, color)
|
||
VALUES ($1, FALSE, $2, $3)
|
||
`, projectName, *userID, randomColor)
|
||
if err != nil {
|
||
// Если ошибка из-за уникальности, пробуем обновить существующий
|
||
_, err = tx.Exec(`
|
||
UPDATE projects
|
||
SET deleted = FALSE, user_id = COALESCE(user_id, $2)
|
||
WHERE name = $1
|
||
`, projectName, *userID)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to upsert project %s: %w", projectName, err)
|
||
}
|
||
}
|
||
} else if err != nil {
|
||
return fmt.Errorf("failed to check project %s: %w", projectName, err)
|
||
}
|
||
// Проект уже существует, ничего не делаем
|
||
} else {
|
||
// Для случая без user_id (legacy)
|
||
var existingID int
|
||
err := tx.QueryRow(`
|
||
SELECT id FROM projects
|
||
WHERE name = $1 AND deleted = FALSE
|
||
`, projectName).Scan(&existingID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
// Проект не существует, создаем новый
|
||
randomColor := generateRandomProjectColor()
|
||
_, err = tx.Exec(`
|
||
INSERT INTO projects (name, deleted, color)
|
||
VALUES ($1, FALSE, $2)
|
||
`, projectName, randomColor)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to insert project %s: %w", projectName, err)
|
||
}
|
||
} else if err != nil {
|
||
return fmt.Errorf("failed to check project %s: %w", projectName, err)
|
||
}
|
||
// Проект уже существует, ничего не делаем
|
||
}
|
||
}
|
||
|
||
// 2. Вставляем entry
|
||
var entryID int
|
||
if userID != nil {
|
||
err = tx.QueryRow(`
|
||
INSERT INTO entries (text, created_date, user_id)
|
||
VALUES ($1, $2, $3)
|
||
RETURNING id
|
||
`, entryText, createdDate, *userID).Scan(&entryID)
|
||
} else {
|
||
err = tx.QueryRow(`
|
||
INSERT INTO entries (text, created_date)
|
||
VALUES ($1, $2)
|
||
RETURNING id
|
||
`, entryText, createdDate).Scan(&entryID)
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("failed to insert entry: %w", err)
|
||
}
|
||
|
||
// 3. Вставляем nodes
|
||
for _, node := range nodes {
|
||
var projectID int
|
||
if userID != nil {
|
||
err = tx.QueryRow(`
|
||
SELECT id FROM projects
|
||
WHERE name = $1 AND user_id = $2 AND deleted = FALSE
|
||
`, node.Project, *userID).Scan(&projectID)
|
||
} else {
|
||
err = tx.QueryRow(`
|
||
SELECT id FROM projects
|
||
WHERE name = $1 AND deleted = FALSE
|
||
`, node.Project).Scan(&projectID)
|
||
}
|
||
|
||
if err == sql.ErrNoRows {
|
||
return fmt.Errorf("project %s not found after insert", node.Project)
|
||
} else if err != nil {
|
||
return fmt.Errorf("failed to find project %s: %w", node.Project, err)
|
||
}
|
||
|
||
// Вставляем node с user_id и created_date (денормализация)
|
||
if userID != nil {
|
||
_, err = tx.Exec(`
|
||
INSERT INTO nodes (project_id, entry_id, score, user_id, created_date)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
`, projectID, entryID, node.Score, *userID, createdDate)
|
||
} else {
|
||
_, err = tx.Exec(`
|
||
INSERT INTO nodes (project_id, entry_id, score, created_date)
|
||
VALUES ($1, $2, $3, $4)
|
||
`, projectID, entryID, node.Score, createdDate)
|
||
}
|
||
if err != nil {
|
||
return fmt.Errorf("failed to insert node for project %s: %w", node.Project, err)
|
||
}
|
||
}
|
||
|
||
// MV обновляется только по крону в понедельник в 6:00 утра
|
||
// Данные текущей недели берутся напрямую из nodes
|
||
|
||
// Коммитим транзакцию
|
||
if err := tx.Commit(); err != nil {
|
||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// setupWeeklyGoals выполняет установку целей на неделю (без HTTP обработки)
|
||
func (a *App) setupWeeklyGoals() error {
|
||
// 1. Выполняем SQL запрос для установки целей
|
||
setupQuery := `
|
||
WITH current_info AS (
|
||
-- Сегодня это будет 2026 год / 1 неделя
|
||
SELECT
|
||
EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AS c_year,
|
||
EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week
|
||
),
|
||
goal_metrics AS (
|
||
-- Считаем медиану на основе данных за последние 4 недели, исключая текущую неделю
|
||
SELECT
|
||
project_id,
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY normalized_total_score) AS median_score
|
||
FROM (
|
||
SELECT
|
||
project_id,
|
||
normalized_total_score,
|
||
report_year,
|
||
report_week,
|
||
-- Нумеруем недели от новых к старым
|
||
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
|
||
FROM weekly_report_mv
|
||
WHERE
|
||
-- Исключаем текущую неделю и все будущие недели
|
||
-- Используем сравнение (year, week) < (current_year, current_week) для корректного исключения
|
||
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
|
||
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
|
||
) sub
|
||
WHERE rn <= 4 -- Берем историю за последние 4 недели, исключая текущую неделю
|
||
GROUP BY project_id
|
||
)
|
||
INSERT INTO weekly_goals (
|
||
project_id,
|
||
goal_year,
|
||
goal_week,
|
||
min_goal_score,
|
||
max_goal_score,
|
||
max_score,
|
||
priority,
|
||
user_id
|
||
)
|
||
SELECT
|
||
p.id,
|
||
ci.c_year,
|
||
ci.c_week,
|
||
-- Если нет данных (gm.median_score IS NULL), используем 0 (значение по умолчанию)
|
||
COALESCE(gm.median_score, 0) AS min_goal_score,
|
||
-- Логика max_score в зависимости от приоритета (только если есть данные)
|
||
CASE
|
||
WHEN gm.median_score IS NULL THEN NULL
|
||
WHEN p.priority = 1 THEN gm.median_score * 2.0
|
||
WHEN p.priority = 2 THEN gm.median_score * 1.7
|
||
ELSE gm.median_score * 1.4
|
||
END AS max_goal_score,
|
||
-- max_score (snapshot) заполняется при INSERT, но НЕ обновляется при конфликте
|
||
CASE
|
||
WHEN gm.median_score IS NULL THEN NULL
|
||
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
|
||
END AS max_score,
|
||
p.priority,
|
||
p.user_id
|
||
FROM projects p
|
||
CROSS JOIN current_info ci
|
||
LEFT JOIN goal_metrics gm ON p.id = gm.project_id
|
||
WHERE p.deleted = FALSE
|
||
ON CONFLICT (project_id, goal_year, goal_week) DO UPDATE
|
||
SET
|
||
min_goal_score = EXCLUDED.min_goal_score,
|
||
max_goal_score = EXCLUDED.max_goal_score,
|
||
priority = EXCLUDED.priority,
|
||
user_id = EXCLUDED.user_id
|
||
`
|
||
|
||
_, err := a.DB.Exec(setupQuery)
|
||
if err != nil {
|
||
log.Printf("Error setting up weekly goals: %v", err)
|
||
return fmt.Errorf("error setting up weekly goals: %w", err)
|
||
}
|
||
|
||
log.Println("Weekly goals setup completed successfully")
|
||
|
||
// Отправляем сообщение в Telegram с зафиксированными целями
|
||
if err := a.sendWeeklyGoalsTelegramMessage(); err != nil {
|
||
log.Printf("Error sending weekly goals Telegram message: %v", err)
|
||
// Не возвращаем ошибку, так как фиксация целей уже выполнена успешно
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// getWeeklyGoalsForUser получает цели для конкретного пользователя
|
||
func (a *App) getWeeklyGoalsForUser(userID int) ([]WeeklyGoalSetup, error) {
|
||
selectQuery := `
|
||
SELECT
|
||
p.name AS project_name,
|
||
wg.min_goal_score,
|
||
wg.max_goal_score
|
||
FROM
|
||
weekly_goals wg
|
||
JOIN
|
||
projects p ON wg.project_id = p.id
|
||
WHERE
|
||
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||
AND p.deleted = FALSE
|
||
AND p.user_id = $1
|
||
ORDER BY
|
||
p.name
|
||
`
|
||
|
||
rows, err := a.DB.Query(selectQuery, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error querying weekly goals: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
goals := make([]WeeklyGoalSetup, 0)
|
||
for rows.Next() {
|
||
var goal WeeklyGoalSetup
|
||
var maxGoalScore sql.NullFloat64
|
||
|
||
err := rows.Scan(
|
||
&goal.ProjectName,
|
||
&goal.MinGoalScore,
|
||
&maxGoalScore,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning weekly goal row: %v", err)
|
||
continue
|
||
}
|
||
|
||
if maxGoalScore.Valid {
|
||
goal.MaxGoalScore = maxGoalScore.Float64
|
||
} else {
|
||
goal.MaxGoalScore = math.NaN()
|
||
}
|
||
|
||
goals = append(goals, goal)
|
||
}
|
||
|
||
return goals, nil
|
||
}
|
||
|
||
// sendWeeklyGoalsTelegramMessage отправляет персональные цели всем пользователям
|
||
func (a *App) sendWeeklyGoalsTelegramMessage() error {
|
||
userIDs, err := a.getAllUsersWithTelegram()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
for _, userID := range userIDs {
|
||
goals, err := a.getWeeklyGoalsForUser(userID)
|
||
if err != nil {
|
||
log.Printf("Error getting goals for user %d: %v", userID, err)
|
||
continue
|
||
}
|
||
|
||
message := a.formatWeeklyGoalsMessage(goals)
|
||
if message == "" {
|
||
continue
|
||
}
|
||
|
||
if err := a.sendTelegramMessageToUser(userID, message); err != nil {
|
||
log.Printf("Error sending weekly goals to user %d: %v", userID, err)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// formatWeeklyGoalsMessage форматирует список целей в сообщение для Telegram
|
||
// Формат аналогичен JS коду из n8n
|
||
func (a *App) formatWeeklyGoalsMessage(goals []WeeklyGoalSetup) string {
|
||
if len(goals) == 0 {
|
||
return ""
|
||
}
|
||
|
||
// Заголовок сообщения: "Цели на неделю"
|
||
markdownMessage := "*🎯 Цели:*\n\n"
|
||
|
||
// Обработка каждого проекта
|
||
for _, goal := range goals {
|
||
// Пропускаем проекты без названия
|
||
if goal.ProjectName == "" {
|
||
continue
|
||
}
|
||
|
||
// Получаем и форматируем цели
|
||
minGoal := goal.MinGoalScore
|
||
maxGoal := goal.MaxGoalScore
|
||
|
||
var goalText string
|
||
|
||
// Форматируем текст цели, если они существуют
|
||
// Проверяем, что minGoal валиден (не NaN)
|
||
// В JS коде проверяется isNaN, поэтому проверяем только на NaN
|
||
if !math.IsNaN(minGoal) {
|
||
minGoalFormatted := fmt.Sprintf("%.2f", minGoal)
|
||
|
||
// Формируем диапазон: [MIN] или [MIN - MAX]
|
||
// maxGoal должен быть валиден (не NaN) для отображения диапазона
|
||
if !math.IsNaN(maxGoal) {
|
||
maxGoalFormatted := fmt.Sprintf("%.2f", maxGoal)
|
||
// Формат: *Проект*: от 15.00 до 20.00
|
||
goalText = fmt.Sprintf(" от %s до %s", minGoalFormatted, maxGoalFormatted)
|
||
} else {
|
||
// Формат: *Проект*: мин. 15.00
|
||
goalText = fmt.Sprintf(" мин. %s", minGoalFormatted)
|
||
}
|
||
} else {
|
||
// Если minGoal не установлен (NaN), пропускаем вывод цели
|
||
continue
|
||
}
|
||
|
||
// Форматирование строки для Markdown (Legacy): *Название*: Цель
|
||
markdownMessage += fmt.Sprintf("*%s*:%s\n", goal.ProjectName, goalText)
|
||
}
|
||
|
||
return markdownMessage
|
||
}
|
||
|
||
func (a *App) weeklyGoalsSetupHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
err := a.setupWeeklyGoals()
|
||
if err != nil {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Получаем установленные цели для ответа
|
||
selectQuery := `
|
||
SELECT
|
||
p.name AS project_name,
|
||
wg.min_goal_score,
|
||
wg.max_goal_score
|
||
FROM
|
||
weekly_goals wg
|
||
JOIN
|
||
projects p ON wg.project_id = p.id
|
||
WHERE
|
||
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||
AND p.deleted = FALSE
|
||
ORDER BY
|
||
p.name
|
||
`
|
||
|
||
rows, err := a.DB.Query(selectQuery)
|
||
if err != nil {
|
||
log.Printf("Error querying weekly goals: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error querying weekly goals: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
goals := make([]WeeklyGoalSetup, 0)
|
||
for rows.Next() {
|
||
var goal WeeklyGoalSetup
|
||
var maxGoalScore sql.NullFloat64
|
||
|
||
err := rows.Scan(
|
||
&goal.ProjectName,
|
||
&goal.MinGoalScore,
|
||
&maxGoalScore,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning weekly goal row: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if maxGoalScore.Valid {
|
||
goal.MaxGoalScore = maxGoalScore.Float64
|
||
} else {
|
||
goal.MaxGoalScore = 0.0
|
||
}
|
||
|
||
goals = append(goals, goal)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(goals)
|
||
}
|
||
|
||
// dailyReportTriggerHandler обрабатывает запрос на отправку ежедневного отчёта
|
||
func (a *App) dailyReportTriggerHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
log.Printf("Manual trigger: Sending daily report")
|
||
err := a.sendDailyReport()
|
||
if err != nil {
|
||
log.Printf("Error in manual daily report trigger: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{
|
||
"message": "Daily report sent successfully",
|
||
})
|
||
}
|
||
|
||
func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) {
|
||
// Пробуем найти файл admin.html в разных местах
|
||
var adminPath string
|
||
|
||
// 1. Пробуем в текущей рабочей директории
|
||
if _, err := os.Stat("admin.html"); err == nil {
|
||
adminPath = "admin.html"
|
||
} else {
|
||
// 2. Пробуем в директории play-life-backend относительно текущей директории
|
||
adminPath = filepath.Join("play-life-backend", "admin.html")
|
||
if _, err := os.Stat(adminPath); err != nil {
|
||
// 3. Пробуем получить путь к исполняемому файлу и искать рядом
|
||
if execPath, err := os.Executable(); err == nil {
|
||
execDir := filepath.Dir(execPath)
|
||
adminPath = filepath.Join(execDir, "admin.html")
|
||
if _, err := os.Stat(adminPath); err != nil {
|
||
// 4. Последняя попытка - просто "admin.html"
|
||
adminPath = "admin.html"
|
||
}
|
||
} else {
|
||
adminPath = "admin.html"
|
||
}
|
||
}
|
||
}
|
||
|
||
http.ServeFile(w, r, adminPath)
|
||
}
|
||
|
||
// recreateMaterializedViewHandler пересоздает materialized view с исправленной логикой ISOYEAR
|
||
func (a *App) recreateMaterializedViewHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
log.Printf("Recreating materialized view weekly_report_mv with ISOYEAR fix")
|
||
|
||
// Удаляем старый view
|
||
dropMaterializedView := `DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv`
|
||
if _, err := a.DB.Exec(dropMaterializedView); err != nil {
|
||
log.Printf("Error dropping materialized view: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error dropping materialized view: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Создаем новый view с ISOYEAR
|
||
createMaterializedView := `
|
||
CREATE MATERIALIZED VIEW weekly_report_mv AS
|
||
SELECT
|
||
p.id AS project_id,
|
||
agg.report_year,
|
||
agg.report_week,
|
||
COALESCE(agg.total_score, 0.0000) AS total_score,
|
||
CASE
|
||
WHEN wg.max_score IS NULL THEN COALESCE(agg.total_score, 0.0000)
|
||
ELSE LEAST(COALESCE(agg.total_score, 0.0000), wg.max_score)
|
||
END AS normalized_total_score
|
||
FROM
|
||
projects p
|
||
LEFT JOIN
|
||
(
|
||
SELECT
|
||
n.project_id,
|
||
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
|
||
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
|
||
SUM(n.score) AS total_score
|
||
FROM
|
||
nodes n
|
||
JOIN
|
||
entries e ON n.entry_id = e.id
|
||
GROUP BY
|
||
1, 2, 3
|
||
) agg
|
||
ON p.id = agg.project_id
|
||
LEFT JOIN
|
||
weekly_goals wg
|
||
ON wg.project_id = p.id
|
||
AND wg.goal_year = agg.report_year
|
||
AND wg.goal_week = agg.report_week
|
||
WHERE
|
||
p.deleted = FALSE
|
||
ORDER BY
|
||
p.id, agg.report_year, agg.report_week
|
||
`
|
||
|
||
if _, err := a.DB.Exec(createMaterializedView); err != nil {
|
||
log.Printf("Error creating materialized view: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating materialized view: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Создаем индекс
|
||
createMVIndex := `
|
||
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
|
||
ON weekly_report_mv(project_id, report_year, report_week)
|
||
`
|
||
if _, err := a.DB.Exec(createMVIndex); err != nil {
|
||
log.Printf("Warning: Failed to create materialized view index: %v", err)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{
|
||
"message": "Materialized view recreated successfully with ISOYEAR fix",
|
||
})
|
||
}
|
||
|
||
func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
query := `
|
||
SELECT
|
||
id AS project_id,
|
||
name AS project_name,
|
||
priority,
|
||
color
|
||
FROM
|
||
projects
|
||
WHERE
|
||
deleted = FALSE AND user_id = $1
|
||
ORDER BY
|
||
priority ASC NULLS LAST,
|
||
project_name
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID)
|
||
if err != nil {
|
||
log.Printf("Error querying projects: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error querying projects: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
projects := make([]Project, 0)
|
||
for rows.Next() {
|
||
var project Project
|
||
var priority sql.NullInt64
|
||
|
||
err := rows.Scan(
|
||
&project.ProjectID,
|
||
&project.ProjectName,
|
||
&priority,
|
||
&project.Color,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning project row: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if priority.Valid {
|
||
priorityVal := int(priority.Int64)
|
||
project.Priority = &priorityVal
|
||
}
|
||
|
||
projects = append(projects, project)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(projects)
|
||
}
|
||
|
||
func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
_ = userID // Will be used in SQL queries
|
||
|
||
// Читаем тело запроса один раз
|
||
bodyBytes, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
log.Printf("Error reading request body: %v", err)
|
||
sendErrorWithCORS(w, "Error reading request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
defer r.Body.Close()
|
||
|
||
// Парсим входящий запрос - может быть как {body: [...]}, так и просто массив
|
||
var projectsToUpdate []ProjectPriorityUpdate
|
||
|
||
// Сначала пробуем декодировать как прямой массив
|
||
var directArray []interface{}
|
||
arrayErr := json.Unmarshal(bodyBytes, &directArray)
|
||
if arrayErr == nil && len(directArray) > 0 {
|
||
// Успешно декодировали как массив
|
||
log.Printf("Received direct array format with %d items", len(directArray))
|
||
for _, item := range directArray {
|
||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||
var project ProjectPriorityUpdate
|
||
|
||
// Извлекаем id
|
||
if idVal, ok := itemMap["id"].(float64); ok {
|
||
project.ID = int(idVal)
|
||
} else if idVal, ok := itemMap["id"].(int); ok {
|
||
project.ID = idVal
|
||
} else {
|
||
log.Printf("Invalid id in request item: %v", itemMap)
|
||
continue
|
||
}
|
||
|
||
// Извлекаем priority (может быть null, undefined, или числом)
|
||
if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil {
|
||
// Проверяем, не является ли это строкой "null"
|
||
if strVal, ok := priorityVal.(string); ok && (strVal == "null" || strVal == "NULL") {
|
||
project.Priority = nil
|
||
} else if numVal, ok := priorityVal.(float64); ok {
|
||
priorityInt := int(numVal)
|
||
project.Priority = &priorityInt
|
||
} else if numVal, ok := priorityVal.(int); ok {
|
||
project.Priority = &numVal
|
||
} else {
|
||
project.Priority = nil
|
||
}
|
||
} else {
|
||
project.Priority = nil
|
||
}
|
||
|
||
projectsToUpdate = append(projectsToUpdate, project)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если не получилось как массив (ошибка декодирования), пробуем как объект с body
|
||
// НЕ пытаемся декодировать как объект, если массив декодировался успешно (даже если пустой)
|
||
if len(projectsToUpdate) == 0 && arrayErr != nil {
|
||
log.Printf("Failed to decode as array (error: %v), trying as object", arrayErr)
|
||
var rawReq map[string]interface{}
|
||
if err := json.Unmarshal(bodyBytes, &rawReq); err != nil {
|
||
log.Printf("Error decoding project priority request as object: %v, body: %s", err, string(bodyBytes))
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Извлекаем массив проектов из body
|
||
if body, ok := rawReq["body"].([]interface{}); ok {
|
||
log.Printf("Received body format with %d items", len(body))
|
||
for _, item := range body {
|
||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||
var project ProjectPriorityUpdate
|
||
|
||
// Извлекаем id
|
||
if idVal, ok := itemMap["id"].(float64); ok {
|
||
project.ID = int(idVal)
|
||
} else if idVal, ok := itemMap["id"].(int); ok {
|
||
project.ID = idVal
|
||
} else {
|
||
log.Printf("Invalid id in request item: %v", itemMap)
|
||
continue
|
||
}
|
||
|
||
// Извлекаем priority (может быть null, undefined, или числом)
|
||
if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil {
|
||
// Проверяем, не является ли это строкой "null"
|
||
if strVal, ok := priorityVal.(string); ok && (strVal == "null" || strVal == "NULL") {
|
||
project.Priority = nil
|
||
} else if numVal, ok := priorityVal.(float64); ok {
|
||
priorityInt := int(numVal)
|
||
project.Priority = &priorityInt
|
||
} else if numVal, ok := priorityVal.(int); ok {
|
||
project.Priority = &numVal
|
||
} else {
|
||
project.Priority = nil
|
||
}
|
||
} else {
|
||
project.Priority = nil
|
||
}
|
||
|
||
projectsToUpdate = append(projectsToUpdate, project)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if len(projectsToUpdate) == 0 {
|
||
log.Printf("No projects to update after parsing. Body was: %s", string(bodyBytes))
|
||
sendErrorWithCORS(w, "No projects to update", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
log.Printf("Successfully parsed %d projects to update", len(projectsToUpdate))
|
||
|
||
// Начинаем транзакцию
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Обновляем приоритеты для каждого проекта
|
||
for _, project := range projectsToUpdate {
|
||
if project.Priority == nil {
|
||
_, err = tx.Exec(`
|
||
UPDATE projects
|
||
SET priority = NULL
|
||
WHERE id = $1 AND user_id = $2
|
||
`, project.ID, userID)
|
||
} else {
|
||
_, err = tx.Exec(`
|
||
UPDATE projects
|
||
SET priority = $1
|
||
WHERE id = $2 AND user_id = $3
|
||
`, *project.Priority, project.ID, userID)
|
||
}
|
||
|
||
if err != nil {
|
||
log.Printf("Error updating project %d priority: %v", project.ID, err)
|
||
tx.Rollback()
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating project %d: %v", project.ID, err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Коммитим транзакцию
|
||
if err := tx.Commit(); err != nil {
|
||
log.Printf("Error committing transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Возвращаем успешный ответ
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": fmt.Sprintf("Updated priorities for %d projects", len(projectsToUpdate)),
|
||
"updated": len(projectsToUpdate),
|
||
})
|
||
}
|
||
|
||
type ProjectColorRequest struct {
|
||
ID int `json:"id"`
|
||
Color string `json:"color"`
|
||
}
|
||
|
||
func (a *App) setProjectColorHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req ProjectColorRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding project color request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.ID == 0 {
|
||
sendErrorWithCORS(w, "id is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.Color == "" {
|
||
sendErrorWithCORS(w, "color is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем, что цвет в правильном формате HEX
|
||
if !strings.HasPrefix(req.Color, "#") || len(req.Color) != 7 {
|
||
sendErrorWithCORS(w, "color must be in HEX format (e.g., #FF5733)", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Обновляем цвет проекта
|
||
_, err := a.DB.Exec(`
|
||
UPDATE projects
|
||
SET color = $1
|
||
WHERE id = $2 AND user_id = $3
|
||
`, req.Color, req.ID, userID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error updating project color: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating project color: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Project color updated successfully",
|
||
"id": req.ID,
|
||
"color": req.Color,
|
||
})
|
||
}
|
||
|
||
type ProjectMoveRequest struct {
|
||
ID int `json:"id"`
|
||
NewName string `json:"new_name"`
|
||
}
|
||
|
||
type ProjectDeleteRequest struct {
|
||
ID int `json:"id"`
|
||
}
|
||
|
||
type ProjectCreateRequest struct {
|
||
Name string `json:"name"`
|
||
}
|
||
|
||
func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
_ = userID // Will be used in SQL queries
|
||
|
||
var req ProjectMoveRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding move project request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.NewName == "" {
|
||
sendErrorWithCORS(w, "new_name is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Начинаем транзакцию
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Ищем проект с таким именем
|
||
var targetProjectID int
|
||
err = tx.QueryRow(`
|
||
SELECT id FROM projects WHERE name = $1 AND deleted = FALSE
|
||
`, req.NewName).Scan(&targetProjectID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
// Проект не найден - просто переименовываем текущий проект
|
||
_, err = tx.Exec(`
|
||
UPDATE projects
|
||
SET name = $1
|
||
WHERE id = $2
|
||
`, req.NewName, req.ID)
|
||
if err != nil {
|
||
log.Printf("Error renaming project: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error renaming project: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Коммитим транзакцию
|
||
if err := tx.Commit(); err != nil {
|
||
log.Printf("Error committing transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Project renamed successfully",
|
||
"project_id": req.ID,
|
||
})
|
||
return
|
||
} else if err != nil {
|
||
log.Printf("Error querying target project: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error querying target project: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Проект найден - переносим данные в существующий проект
|
||
finalProjectID := targetProjectID
|
||
|
||
// Обновляем все nodes с project_id на целевой
|
||
_, err = tx.Exec(`
|
||
UPDATE nodes
|
||
SET project_id = $1
|
||
WHERE project_id = $2
|
||
`, finalProjectID, req.ID)
|
||
if err != nil {
|
||
log.Printf("Error updating nodes: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating nodes: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Обновляем weekly_goals
|
||
// Сначала удаляем записи старого проекта, которые конфликтуют с записями целевого проекта
|
||
// (если у целевого проекта уже есть запись для той же недели)
|
||
_, err = tx.Exec(`
|
||
DELETE FROM weekly_goals
|
||
WHERE project_id = $1
|
||
AND EXISTS (
|
||
SELECT 1
|
||
FROM weekly_goals wg2
|
||
WHERE wg2.project_id = $2
|
||
AND wg2.goal_year = weekly_goals.goal_year
|
||
AND wg2.goal_week = weekly_goals.goal_week
|
||
)
|
||
`, req.ID, finalProjectID)
|
||
if err != nil {
|
||
log.Printf("Error deleting conflicting weekly_goals: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error deleting conflicting weekly_goals: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Теперь обновляем оставшиеся записи (те, которые не конфликтуют)
|
||
// Обновляем project_id и user_id из целевого проекта
|
||
_, err = tx.Exec(`
|
||
UPDATE weekly_goals wg
|
||
SET project_id = $1, user_id = p.user_id
|
||
FROM projects p
|
||
WHERE wg.project_id = $2
|
||
AND p.id = $1
|
||
`, finalProjectID, req.ID)
|
||
if err != nil {
|
||
log.Printf("Error updating weekly_goals: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating weekly_goals: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Помечаем старый проект как удаленный
|
||
_, err = tx.Exec(`
|
||
UPDATE projects
|
||
SET deleted = TRUE
|
||
WHERE id = $1
|
||
`, req.ID)
|
||
if err != nil {
|
||
log.Printf("Error marking project as deleted: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error marking project as deleted: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Коммитим транзакцию
|
||
if err := tx.Commit(); err != nil {
|
||
log.Printf("Error committing transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// MV обновляется только по крону в понедельник в 6:00 утра
|
||
// Данные текущей недели берутся напрямую из nodes
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Project moved successfully",
|
||
"project_id": finalProjectID,
|
||
})
|
||
}
|
||
|
||
func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req ProjectDeleteRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding delete project request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Verify ownership
|
||
var ownerID int
|
||
err := a.DB.QueryRow("SELECT user_id FROM projects WHERE id = $1", req.ID).Scan(&ownerID)
|
||
if err != nil || ownerID != userID {
|
||
sendErrorWithCORS(w, "Project not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
// Начинаем транзакцию
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Удаляем все записи weekly_goals для этого проекта
|
||
_, err = tx.Exec(`
|
||
DELETE FROM weekly_goals
|
||
WHERE project_id = $1
|
||
`, req.ID)
|
||
if err != nil {
|
||
log.Printf("Error deleting weekly_goals: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error deleting weekly_goals: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Помечаем проект как удаленный
|
||
_, err = tx.Exec(`
|
||
UPDATE projects
|
||
SET deleted = TRUE
|
||
WHERE id = $1
|
||
`, req.ID)
|
||
if err != nil {
|
||
log.Printf("Error marking project as deleted: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error marking project as deleted: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Коммитим транзакцию
|
||
if err := tx.Commit(); err != nil {
|
||
log.Printf("Error committing transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// MV обновляется только по крону в понедельник в 6:00 утра
|
||
// Данные текущей недели берутся напрямую из nodes
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Project deleted successfully",
|
||
})
|
||
}
|
||
|
||
func (a *App) createProjectHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req ProjectCreateRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding create project request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.Name == "" {
|
||
sendErrorWithCORS(w, "name is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем, существует ли уже проект с таким именем
|
||
var existingID int
|
||
err := a.DB.QueryRow(`
|
||
SELECT id FROM projects
|
||
WHERE name = $1 AND user_id = $2 AND deleted = FALSE
|
||
`, req.Name, userID).Scan(&existingID)
|
||
|
||
if err == nil {
|
||
// Проект уже существует
|
||
sendErrorWithCORS(w, "Project with this name already exists", http.StatusConflict)
|
||
return
|
||
} else if err != sql.ErrNoRows {
|
||
log.Printf("Error checking project existence: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking project existence: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Создаем новый проект
|
||
randomColor := generateRandomProjectColor()
|
||
var projectID int
|
||
err = a.DB.QueryRow(`
|
||
INSERT INTO projects (name, deleted, user_id, color)
|
||
VALUES ($1, FALSE, $2, $3)
|
||
RETURNING id
|
||
`, req.Name, userID, randomColor).Scan(&projectID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating project: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating project: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Project created successfully",
|
||
"project_id": projectID,
|
||
"project_name": req.Name,
|
||
})
|
||
}
|
||
|
||
func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||
// Логирование входящего запроса
|
||
log.Printf("=== Todoist Webhook Request ===")
|
||
log.Printf("Method: %s", r.Method)
|
||
log.Printf("URL: %s", r.URL.String())
|
||
log.Printf("Path: %s", r.URL.Path)
|
||
log.Printf("RemoteAddr: %s", r.RemoteAddr)
|
||
|
||
if r.Method == "OPTIONS" {
|
||
log.Printf("OPTIONS request, returning OK")
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
// Проверка webhook secret (если настроен)
|
||
todoistWebhookSecret := getEnv("TODOIST_WEBHOOK_SECRET", "")
|
||
if todoistWebhookSecret != "" {
|
||
providedSecret := r.Header.Get("X-Todoist-Hmac-SHA256")
|
||
if providedSecret == "" {
|
||
providedSecret = r.Header.Get("X-Todoist-Webhook-Secret")
|
||
}
|
||
if providedSecret != todoistWebhookSecret {
|
||
log.Printf("Invalid Todoist webhook secret provided")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": false,
|
||
"error": "Unauthorized",
|
||
"message": "Invalid webhook secret",
|
||
})
|
||
return
|
||
}
|
||
log.Printf("Webhook secret validated successfully")
|
||
}
|
||
|
||
// Читаем тело запроса
|
||
bodyBytes, err := io.ReadAll(r.Body)
|
||
if err != nil {
|
||
log.Printf("Error reading request body: %v", err)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": false,
|
||
"error": "Error reading request body",
|
||
"message": "Failed to read request",
|
||
})
|
||
return
|
||
}
|
||
|
||
log.Printf("Request body (raw): %s", string(bodyBytes))
|
||
log.Printf("Request body length: %d bytes", len(bodyBytes))
|
||
|
||
// Парсим webhook от Todoist
|
||
var webhook TodoistWebhook
|
||
if err := json.Unmarshal(bodyBytes, &webhook); err != nil {
|
||
log.Printf("Error decoding Todoist webhook: %v", err)
|
||
log.Printf("Failed to parse body as JSON: %s", string(bodyBytes))
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": false,
|
||
"error": "Invalid request body",
|
||
"message": "Failed to parse JSON",
|
||
})
|
||
return
|
||
}
|
||
|
||
// Логируем структуру webhook
|
||
log.Printf("Parsed webhook structure:")
|
||
log.Printf(" EventName: %s", webhook.EventName)
|
||
log.Printf(" EventData keys: %v", getMapKeys(webhook.EventData))
|
||
|
||
// Проверяем, что это событие закрытия задачи
|
||
if webhook.EventName != "item:completed" {
|
||
log.Printf("Received Todoist event '%s', ignoring (only processing 'item:completed')", webhook.EventName)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": true,
|
||
"message": "Event ignored",
|
||
"event": webhook.EventName,
|
||
})
|
||
return
|
||
}
|
||
|
||
// Извлекаем user_id из event_data (это Todoist user_id!)
|
||
var todoistUserID int64
|
||
switch v := webhook.EventData["user_id"].(type) {
|
||
case float64:
|
||
todoistUserID = int64(v)
|
||
case string:
|
||
todoistUserID, _ = strconv.ParseInt(v, 10, 64)
|
||
default:
|
||
log.Printf("Todoist webhook: user_id not found or invalid type in event_data")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": false,
|
||
"error": "Missing user_id in event_data",
|
||
"message": "Cannot identify user",
|
||
})
|
||
return
|
||
}
|
||
|
||
log.Printf("Todoist webhook: todoist_user_id=%d", todoistUserID)
|
||
|
||
// Находим пользователя Play Life по todoist_user_id
|
||
var userID int
|
||
err = a.DB.QueryRow(`
|
||
SELECT user_id FROM todoist_integrations
|
||
WHERE todoist_user_id = $1
|
||
`, todoistUserID).Scan(&userID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
// Пользователь не подключил Play Life — игнорируем
|
||
log.Printf("Todoist webhook: no user found for todoist_user_id=%d (ignoring)", todoistUserID)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": true,
|
||
"message": "User not found (not connected)",
|
||
})
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error finding user by todoist_user_id: %v", err)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": false,
|
||
"error": "Internal server error",
|
||
"message": "Database error",
|
||
})
|
||
return
|
||
}
|
||
|
||
log.Printf("Todoist webhook: todoist_user_id=%d -> user_id=%d", todoistUserID, userID)
|
||
|
||
// Извлекаем content (title) и description из event_data
|
||
log.Printf("Extracting content and description from event_data...")
|
||
var title, description string
|
||
|
||
if content, ok := webhook.EventData["content"].(string); ok {
|
||
title = strings.TrimSpace(content)
|
||
log.Printf(" Found 'content' (title): '%s' (length: %d)", title, len(title))
|
||
} else {
|
||
log.Printf(" 'content' not found or not a string (type: %T, value: %v)", webhook.EventData["content"], webhook.EventData["content"])
|
||
}
|
||
|
||
if desc, ok := webhook.EventData["description"].(string); ok {
|
||
description = strings.TrimSpace(desc)
|
||
log.Printf(" Found 'description': '%s' (length: %d)", description, len(description))
|
||
} else {
|
||
log.Printf(" 'description' not found or not a string (type: %T, value: %v)", webhook.EventData["description"], webhook.EventData["description"])
|
||
}
|
||
|
||
// Склеиваем title и description
|
||
// Логика: если есть оба - склеиваем через \n, если только один - используем его
|
||
var combinedText string
|
||
if title != "" && description != "" {
|
||
combinedText = title + "\n" + description
|
||
log.Printf(" Both title and description present, combining them")
|
||
} else if title != "" {
|
||
combinedText = title
|
||
log.Printf(" Only title present, using title only")
|
||
} else if description != "" {
|
||
combinedText = description
|
||
log.Printf(" Only description present, using description only")
|
||
} else {
|
||
combinedText = ""
|
||
log.Printf(" WARNING: Both title and description are empty!")
|
||
}
|
||
log.Printf("Combined text result: '%s' (length: %d)", combinedText, len(combinedText))
|
||
|
||
// Проверяем, что есть хотя бы title или description
|
||
if combinedText == "" {
|
||
log.Printf("ERROR: Todoist webhook: no content or description found in event_data")
|
||
log.Printf(" title='%s' (empty: %v), description='%s' (empty: %v)", title, title == "", description, description == "")
|
||
log.Printf("Available keys in event_data: %v", getMapKeys(webhook.EventData))
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": false,
|
||
"error": "Missing 'content' or 'description' in event_data",
|
||
"message": "No content to process",
|
||
})
|
||
return
|
||
}
|
||
|
||
log.Printf("Processing Todoist task: title='%s' (len=%d), description='%s' (len=%d), combined='%s' (len=%d)",
|
||
title, len(title), description, len(description), combinedText, len(combinedText))
|
||
|
||
// Обрабатываем сообщение через существующую логику (без отправки в Telegram)
|
||
userIDPtr := &userID
|
||
log.Printf("Calling processMessageWithoutTelegram with combined text, user_id=%d...", userID)
|
||
response, err := a.processMessageWithoutTelegram(combinedText, userIDPtr)
|
||
if err != nil {
|
||
log.Printf("ERROR processing Todoist message: %v", err)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": false,
|
||
"error": err.Error(),
|
||
"message": "Error processing message",
|
||
})
|
||
return
|
||
}
|
||
|
||
// Проверяем наличие nodes - если их нет, игнорируем сообщение
|
||
if len(response.Nodes) == 0 {
|
||
log.Printf("Todoist webhook: no nodes found in message, ignoring (not saving to database and not sending to Telegram)")
|
||
log.Printf("=== Todoist Webhook Request Ignored (No Nodes) ===")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": true,
|
||
"message": "Message ignored (no nodes found)",
|
||
"ignored": true,
|
||
})
|
||
return
|
||
}
|
||
|
||
log.Printf("Successfully processed Todoist task, found %d nodes", len(response.Nodes))
|
||
if len(response.Nodes) > 0 {
|
||
log.Printf("Nodes details:")
|
||
for i, node := range response.Nodes {
|
||
log.Printf(" Node %d: Project='%s', Score=%f", i+1, node.Project, node.Score)
|
||
}
|
||
|
||
// Отправляем сообщение в Telegram после успешной обработки
|
||
log.Printf("Preparing to send message to Telegram...")
|
||
log.Printf("Combined text to send: '%s'", combinedText)
|
||
if err := a.sendTelegramMessageToUser(userID, combinedText); err != nil {
|
||
log.Printf("Error sending Telegram message: %v", err)
|
||
} else {
|
||
log.Printf("sendTelegramMessage call completed")
|
||
}
|
||
} else {
|
||
log.Printf("No nodes found, skipping Telegram message")
|
||
}
|
||
|
||
log.Printf("=== Todoist Webhook Request Completed Successfully ===")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": true,
|
||
"message": "Task processed successfully",
|
||
"result": response,
|
||
})
|
||
}
|
||
|
||
func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
// Парсим webhook от Telegram
|
||
var update TelegramUpdate
|
||
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
||
log.Printf("Error decoding Telegram webhook: %v", err)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": false,
|
||
"error": "Invalid request body",
|
||
})
|
||
return
|
||
}
|
||
|
||
// Определяем сообщение
|
||
var message *TelegramMessage
|
||
if update.Message != nil {
|
||
message = update.Message
|
||
} else if update.EditedMessage != nil {
|
||
message = update.EditedMessage
|
||
} else {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||
return
|
||
}
|
||
|
||
if message.From == nil {
|
||
log.Printf("Telegram webhook: message without From field")
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||
return
|
||
}
|
||
|
||
telegramUserID := message.From.ID
|
||
chatID := message.Chat.ID
|
||
chatIDStr := strconv.FormatInt(chatID, 10)
|
||
|
||
log.Printf("Telegram webhook: telegram_user_id=%d, chat_id=%d, text=%s",
|
||
telegramUserID, chatID, message.Text)
|
||
|
||
// Обработка команды /start с токеном
|
||
if strings.HasPrefix(message.Text, "/start") {
|
||
parts := strings.Fields(message.Text)
|
||
if len(parts) > 1 {
|
||
startToken := parts[1]
|
||
|
||
var userID int
|
||
err := a.DB.QueryRow(`
|
||
SELECT user_id FROM telegram_integrations
|
||
WHERE start_token = $1
|
||
`, startToken).Scan(&userID)
|
||
|
||
if err == nil {
|
||
// Привязываем Telegram к пользователю
|
||
telegramUserIDStr := strconv.FormatInt(telegramUserID, 10)
|
||
_, err = a.DB.Exec(`
|
||
UPDATE telegram_integrations
|
||
SET telegram_user_id = $1,
|
||
chat_id = $2,
|
||
start_token = NULL,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE user_id = $3
|
||
`, telegramUserIDStr, chatIDStr, userID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error updating telegram integration: %v", err)
|
||
} else {
|
||
log.Printf("Telegram connected for user_id=%d", userID)
|
||
|
||
// Приветственное сообщение
|
||
welcomeMsg := "✅ Telegram успешно подключен к Play Life!\n\nТеперь вы будете получать уведомления и отчеты."
|
||
if err := a.sendTelegramMessageToChat(chatID, welcomeMsg); err != nil {
|
||
log.Printf("Error sending welcome message: %v", err)
|
||
}
|
||
}
|
||
} else {
|
||
log.Printf("Invalid start_token: %s", startToken)
|
||
a.sendTelegramMessageToChat(chatID, "❌ Неверный токен. Попробуйте получить новую ссылку в приложении.")
|
||
}
|
||
} else {
|
||
// /start без токена
|
||
a.sendTelegramMessageToChat(chatID, "Привет! Для подключения используйте ссылку из приложения Play Life.")
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||
return
|
||
}
|
||
|
||
// Обычное сообщение - ищем пользователя по telegram_user_id
|
||
var userID int
|
||
err := a.DB.QueryRow(`
|
||
SELECT user_id FROM telegram_integrations
|
||
WHERE telegram_user_id = $1
|
||
`, telegramUserID).Scan(&userID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
log.Printf("User not found for telegram_user_id=%d", telegramUserID)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||
return
|
||
} else if err != nil {
|
||
log.Printf("Error finding user: %v", err)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
|
||
// Обновляем chat_id (на случай переподключения)
|
||
a.DB.Exec(`
|
||
UPDATE telegram_integrations
|
||
SET chat_id = $1, updated_at = CURRENT_TIMESTAMP
|
||
WHERE user_id = $2
|
||
`, chatIDStr, userID)
|
||
|
||
// Обрабатываем сообщение
|
||
if message.Text == "" {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||
return
|
||
}
|
||
|
||
entities := message.Entities
|
||
if entities == nil {
|
||
entities = []TelegramEntity{}
|
||
}
|
||
|
||
userIDPtr := &userID
|
||
response, err := a.processTelegramMessage(message.Text, entities, userIDPtr)
|
||
if err != nil {
|
||
log.Printf("Error processing message: %v", err)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"ok": true,
|
||
"result": response,
|
||
})
|
||
}
|
||
|
||
func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Получаем данные текущей недели
|
||
currentWeekScores, err := a.getCurrentWeekScores(userID)
|
||
if err != nil {
|
||
log.Printf("Error getting current week scores: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting current week scores: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Получаем ISO год и неделю для текущей даты
|
||
now := time.Now()
|
||
_, currentWeekInt := now.ISOWeek()
|
||
currentYearInt := now.Year()
|
||
|
||
query := `
|
||
SELECT
|
||
p.name AS project_name,
|
||
-- Определяем год и неделю, беря значение из той таблицы, где оно не NULL
|
||
COALESCE(wr.report_year, wg.goal_year) AS report_year,
|
||
COALESCE(wr.report_week, wg.goal_week) AS report_week,
|
||
|
||
-- Фактический score: COALESCE(NULL, 0.0000)
|
||
COALESCE(wr.total_score, 0.0000) AS total_score,
|
||
|
||
-- Normalized score из MV
|
||
COALESCE(wr.normalized_total_score, 0.0000) AS normalized_total_score,
|
||
|
||
-- Минимальная цель: COALESCE(NULL, 0.0000)
|
||
COALESCE(wg.min_goal_score, 0.0000) AS min_goal_score,
|
||
|
||
-- Максимальная цель: COALESCE(NULL, 0.0000)
|
||
COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score,
|
||
p.id AS project_id,
|
||
p.color
|
||
FROM
|
||
weekly_report_mv wr
|
||
FULL OUTER JOIN
|
||
weekly_goals wg
|
||
-- Слияние по всем трем ключевым полям
|
||
ON wr.project_id = wg.project_id
|
||
AND wr.report_year = wg.goal_year
|
||
AND wr.report_week = wg.goal_week
|
||
JOIN
|
||
projects p
|
||
-- Присоединяем имя проекта, используя ID из той таблицы, где он не NULL
|
||
ON p.id = COALESCE(wr.project_id, wg.project_id)
|
||
WHERE
|
||
p.deleted = FALSE AND p.user_id = $1
|
||
AND COALESCE(wr.report_year, wg.goal_year) IS NOT NULL
|
||
ORDER BY
|
||
report_year DESC,
|
||
report_week DESC,
|
||
project_name
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID)
|
||
if err != nil {
|
||
log.Printf("Error querying full statistics: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error querying full statistics: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
statistics := make([]FullStatisticsItem, 0)
|
||
|
||
for rows.Next() {
|
||
var item FullStatisticsItem
|
||
var projectID int
|
||
|
||
err := rows.Scan(
|
||
&item.ProjectName,
|
||
&item.ReportYear,
|
||
&item.ReportWeek,
|
||
&item.TotalScore,
|
||
&item.NormalizedTotalScore,
|
||
&item.MinGoalScore,
|
||
&item.MaxGoalScore,
|
||
&projectID,
|
||
&item.Color,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning full statistics row: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Если это текущая неделя, заменяем данные из MV на данные из nodes
|
||
if item.ReportYear == currentYearInt && item.ReportWeek == currentWeekInt {
|
||
if score, exists := currentWeekScores[projectID]; exists {
|
||
item.TotalScore = score
|
||
// Для текущей недели normalized_total_score не отправляем
|
||
item.NormalizedTotalScore = 0
|
||
}
|
||
}
|
||
|
||
// Если normalized_total_score равен total_score, не отправляем его
|
||
if item.NormalizedTotalScore == item.TotalScore {
|
||
item.NormalizedTotalScore = 0
|
||
}
|
||
|
||
statistics = append(statistics, item)
|
||
}
|
||
|
||
// Добавляем проекты текущей недели, которых нет в MV (новые проекты без исторических данных)
|
||
// Получаем goals для текущей недели
|
||
currentWeekGoalsQuery := `
|
||
SELECT
|
||
p.id AS project_id,
|
||
p.name AS project_name,
|
||
COALESCE(wg.min_goal_score, 0.0000) AS min_goal_score,
|
||
COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score,
|
||
p.color
|
||
FROM projects p
|
||
LEFT JOIN weekly_goals wg ON wg.project_id = p.id
|
||
AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||
WHERE p.deleted = FALSE AND p.user_id = $1
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM weekly_report_mv wr
|
||
WHERE wr.project_id = p.id
|
||
AND wr.report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||
AND wr.report_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||
)
|
||
`
|
||
|
||
goalsRows, err := a.DB.Query(currentWeekGoalsQuery, userID)
|
||
if err == nil {
|
||
defer goalsRows.Close()
|
||
existingProjects := make(map[int]bool)
|
||
for _, stat := range statistics {
|
||
if stat.ReportYear == currentYearInt && stat.ReportWeek == currentWeekInt {
|
||
// Найдем project_id по имени проекта (не идеально, но работает)
|
||
var pid int
|
||
if err := a.DB.QueryRow("SELECT id FROM projects WHERE name = $1 AND user_id = $2", stat.ProjectName, userID).Scan(&pid); err == nil {
|
||
existingProjects[pid] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
for goalsRows.Next() {
|
||
var projectID int
|
||
var projectName string
|
||
var minGoalScore, maxGoalScore float64
|
||
var projectColor string
|
||
if err := goalsRows.Scan(&projectID, &projectName, &minGoalScore, &maxGoalScore, &projectColor); err == nil {
|
||
// Добавляем только если проекта еще нет в статистике
|
||
if !existingProjects[projectID] {
|
||
totalScore := 0.0
|
||
if score, exists := currentWeekScores[projectID]; exists {
|
||
totalScore = score
|
||
}
|
||
|
||
// Для текущей недели normalized_total_score не отправляем
|
||
_, weekISO := time.Now().ISOWeek()
|
||
item := FullStatisticsItem{
|
||
ProjectName: projectName,
|
||
ReportYear: time.Now().Year(),
|
||
ReportWeek: weekISO,
|
||
TotalScore: totalScore,
|
||
NormalizedTotalScore: 0,
|
||
MinGoalScore: minGoalScore,
|
||
MaxGoalScore: maxGoalScore,
|
||
Color: projectColor,
|
||
}
|
||
statistics = append(statistics, item)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(statistics)
|
||
}
|
||
|
||
// getTodayEntriesHandler возвращает entries с nodes за сегодняшний день
|
||
func (a *App) getTodayEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Получаем опциональные параметры из query string
|
||
projectName := r.URL.Query().Get("project")
|
||
var projectFilter *string
|
||
if projectName != "" {
|
||
projectFilter = &projectName
|
||
}
|
||
|
||
// Получаем дату из query string (формат: YYYY-MM-DD), если не указана - используем сегодня
|
||
dateParam := r.URL.Query().Get("date")
|
||
var targetDate time.Time
|
||
if dateParam != "" {
|
||
parsedDate, err := time.Parse("2006-01-02", dateParam)
|
||
if err != nil {
|
||
log.Printf("Error parsing date parameter: %v", err)
|
||
sendErrorWithCORS(w, "Invalid date format. Use YYYY-MM-DD", http.StatusBadRequest)
|
||
return
|
||
}
|
||
targetDate = parsedDate
|
||
} else {
|
||
targetDate = time.Now()
|
||
}
|
||
|
||
// Запрос для получения entries с nodes за указанный день
|
||
// Если указан проект, показываем все записи, которые содержат хотя бы одну ноду этого проекта,
|
||
// но возвращаем все ноды этих записей, а не только ноды выбранного проекта
|
||
query := `
|
||
WITH filtered_entries AS (
|
||
-- Если проект указан, находим entry_id записей, содержащих хотя бы одну ноду этого проекта
|
||
SELECT DISTINCT e.id as entry_id
|
||
FROM entries e
|
||
JOIN nodes n ON n.entry_id = e.id
|
||
JOIN projects p ON n.project_id = p.id
|
||
WHERE DATE(n.created_date) = DATE($3)
|
||
AND e.user_id = $1
|
||
AND n.user_id = $1
|
||
AND p.user_id = $1
|
||
AND p.deleted = FALSE
|
||
AND ($2::text IS NULL OR p.name = $2)
|
||
),
|
||
entry_nodes AS (
|
||
-- Получаем все ноды для найденных записей (или всех записей, если проект не указан)
|
||
SELECT
|
||
e.id as entry_id,
|
||
e.text,
|
||
e.created_date,
|
||
p.name as project_name,
|
||
n.score,
|
||
ROW_NUMBER() OVER (PARTITION BY e.id ORDER BY n.id) - 1 as node_index
|
||
FROM entries e
|
||
JOIN nodes n ON n.entry_id = e.id
|
||
JOIN projects p ON n.project_id = p.id
|
||
WHERE DATE(n.created_date) = DATE($3)
|
||
AND e.user_id = $1
|
||
AND n.user_id = $1
|
||
AND p.user_id = $1
|
||
AND p.deleted = FALSE
|
||
AND ($2::text IS NULL OR e.id IN (SELECT entry_id FROM filtered_entries))
|
||
)
|
||
SELECT
|
||
entry_id,
|
||
text,
|
||
created_date,
|
||
json_agg(
|
||
json_build_object(
|
||
'project_name', project_name,
|
||
'score', score,
|
||
'index', node_index
|
||
) ORDER BY node_index
|
||
) as nodes
|
||
FROM entry_nodes
|
||
GROUP BY entry_id, text, created_date
|
||
ORDER BY created_date DESC
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID, projectFilter, targetDate)
|
||
if err != nil {
|
||
log.Printf("Error querying today entries: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error querying today entries: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
entries := make([]TodayEntry, 0)
|
||
|
||
for rows.Next() {
|
||
var entry TodayEntry
|
||
var createdDate time.Time
|
||
var nodesJSON string
|
||
|
||
err := rows.Scan(
|
||
&entry.ID,
|
||
&entry.Text,
|
||
&createdDate,
|
||
&nodesJSON,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning today entry row: %v", err)
|
||
continue
|
||
}
|
||
|
||
// Парсим JSON с nodes
|
||
if err := json.Unmarshal([]byte(nodesJSON), &entry.Nodes); err != nil {
|
||
log.Printf("Error unmarshaling nodes JSON: %v", err)
|
||
continue
|
||
}
|
||
|
||
// Форматируем дату в ISO 8601
|
||
entry.CreatedDate = createdDate.Format(time.RFC3339)
|
||
|
||
entries = append(entries, entry)
|
||
}
|
||
|
||
if err := rows.Err(); err != nil {
|
||
log.Printf("Error iterating today entries rows: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error iterating rows: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(entries)
|
||
}
|
||
|
||
// deleteEntryHandler удаляет entry и каскадно удаляет связанные nodes
|
||
func (a *App) deleteEntryHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
entryIDStr := vars["id"]
|
||
entryID, err := strconv.Atoi(entryIDStr)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid entry ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем, что entry принадлежит пользователю
|
||
var entryUserID int
|
||
err = a.DB.QueryRow("SELECT user_id FROM entries WHERE id = $1", entryID).Scan(&entryUserID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Entry not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking entry ownership: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Проверяем права доступа
|
||
if entryUserID != userID {
|
||
sendErrorWithCORS(w, "Forbidden", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Удаляем entry (nodes удалятся каскадно из-за ON DELETE CASCADE)
|
||
result, err := a.DB.Exec("DELETE FROM entries WHERE id = $1 AND user_id = $2", entryID, userID)
|
||
if err != nil {
|
||
log.Printf("Error deleting entry: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
rowsAffected, err := result.RowsAffected()
|
||
if err != nil {
|
||
log.Printf("Error getting rows affected: %v", err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if rowsAffected == 0 {
|
||
sendErrorWithCORS(w, "Entry not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"message": "Entry deleted successfully",
|
||
})
|
||
}
|
||
|
||
// getTelegramIntegrationHandler возвращает текущую telegram интеграцию с deep link
|
||
func (a *App) getTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
integration, err := a.getTelegramIntegrationForUser(userID)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, fmt.Sprintf("Failed to get telegram integration: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Генерируем start_token если его нет
|
||
if integration.StartToken == nil || *integration.StartToken == "" {
|
||
token, err := generateWebhookToken()
|
||
if err == nil {
|
||
_, _ = a.DB.Exec(`
|
||
UPDATE telegram_integrations
|
||
SET start_token = $1, updated_at = CURRENT_TIMESTAMP
|
||
WHERE user_id = $2
|
||
`, token, userID)
|
||
integration.StartToken = &token
|
||
}
|
||
}
|
||
|
||
// Формируем deep link
|
||
var deepLink string
|
||
if a.telegramBotUsername != "" && integration.StartToken != nil {
|
||
deepLink = fmt.Sprintf("https://t.me/%s?start=%s", a.telegramBotUsername, *integration.StartToken)
|
||
}
|
||
|
||
isConnected := integration.ChatID != nil && integration.TelegramUserID != nil
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"id": integration.ID,
|
||
"telegram_user_id": integration.TelegramUserID,
|
||
"is_connected": isConnected,
|
||
"deep_link": deepLink,
|
||
})
|
||
}
|
||
|
||
// updateTelegramIntegrationHandler больше не используется (bot_token теперь в .env)
|
||
// Оставлен для совместимости, возвращает ошибку
|
||
func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
sendErrorWithCORS(w, "Bot token is now configured via TELEGRAM_BOT_TOKEN environment variable", http.StatusBadRequest)
|
||
}
|
||
|
||
// OAuthStateClaims структура для OAuth state JWT
|
||
type OAuthStateClaims struct {
|
||
UserID int `json:"user_id"`
|
||
Type string `json:"type"`
|
||
jwt.RegisteredClaims
|
||
}
|
||
|
||
// generateOAuthState генерирует JWT state для OAuth
|
||
func generateOAuthState(userID int, jwtSecret []byte) (string, error) {
|
||
claims := OAuthStateClaims{
|
||
UserID: userID,
|
||
Type: "todoist_oauth",
|
||
RegisteredClaims: jwt.RegisteredClaims{
|
||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // 1 день
|
||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||
},
|
||
}
|
||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||
return token.SignedString(jwtSecret)
|
||
}
|
||
|
||
// validateOAuthState проверяет и извлекает user_id из JWT state
|
||
func validateOAuthState(stateString string, jwtSecret []byte) (int, error) {
|
||
token, err := jwt.ParseWithClaims(stateString, &OAuthStateClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||
}
|
||
return jwtSecret, nil
|
||
})
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
claims, ok := token.Claims.(*OAuthStateClaims)
|
||
if !ok || !token.Valid {
|
||
return 0, fmt.Errorf("invalid token")
|
||
}
|
||
|
||
if claims.Type != "todoist_oauth" {
|
||
return 0, fmt.Errorf("wrong token type")
|
||
}
|
||
|
||
return claims.UserID, nil
|
||
}
|
||
|
||
// exchangeCodeForToken обменивает OAuth code на access_token
|
||
func exchangeCodeForToken(code, redirectURI, clientID, clientSecret string) (string, error) {
|
||
data := url.Values{}
|
||
data.Set("client_id", clientID)
|
||
data.Set("client_secret", clientSecret)
|
||
data.Set("code", code)
|
||
data.Set("redirect_uri", redirectURI)
|
||
|
||
resp, err := http.PostForm("https://todoist.com/oauth/access_token", data)
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to exchange code: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
body, _ := io.ReadAll(resp.Body)
|
||
return "", fmt.Errorf("token exchange failed: %s", string(body))
|
||
}
|
||
|
||
var result struct {
|
||
AccessToken string `json:"access_token"`
|
||
Error string `json:"error"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||
}
|
||
|
||
if result.Error != "" {
|
||
return "", fmt.Errorf("token exchange error: %s", result.Error)
|
||
}
|
||
|
||
return result.AccessToken, nil
|
||
}
|
||
|
||
// getTodoistUserInfo получает информацию о пользователе через Sync API
|
||
func getTodoistUserInfo(accessToken string) (struct {
|
||
ID int64
|
||
Email string
|
||
}, error) {
|
||
var userInfo struct {
|
||
ID int64
|
||
Email string
|
||
}
|
||
|
||
// Формируем правильный запрос к Sync API
|
||
data := url.Values{}
|
||
data.Set("sync_token", "*")
|
||
data.Set("resource_types", `["user"]`)
|
||
|
||
req, err := http.NewRequest("POST", "https://api.todoist.com/sync/v9/sync", strings.NewReader(data.Encode()))
|
||
if err != nil {
|
||
log.Printf("Todoist API: failed to create request: %v", err)
|
||
return userInfo, err
|
||
}
|
||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.Header.Set("User-Agent", "PlayLife")
|
||
|
||
log.Printf("Todoist API: requesting user info from sync/v9/sync")
|
||
|
||
client := &http.Client{Timeout: 10 * time.Second}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
log.Printf("Todoist API: request failed: %v", err)
|
||
return userInfo, fmt.Errorf("failed to get user info: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||
log.Printf("Todoist API: response status=%d, body=%s", resp.StatusCode, string(bodyBytes))
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return userInfo, fmt.Errorf("get user info failed (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||
}
|
||
|
||
// Парсим ответ - в Sync API user может быть объектом или массивом
|
||
var result map[string]interface{}
|
||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||
log.Printf("Todoist API: failed to parse JSON: %v, body: %s", err, string(bodyBytes))
|
||
return userInfo, fmt.Errorf("failed to decode user info: %w", err)
|
||
}
|
||
|
||
log.Printf("Todoist API: parsed response keys: %v", getMapKeys(result))
|
||
|
||
// Функция для извлечения ID из разных типов
|
||
extractID := func(idValue interface{}) int64 {
|
||
switch v := idValue.(type) {
|
||
case float64:
|
||
return int64(v)
|
||
case int64:
|
||
return v
|
||
case int:
|
||
return int64(v)
|
||
case string:
|
||
if id, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||
return id
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// Проверяем разные варианты структуры ответа
|
||
if userObj, ok := result["user"].(map[string]interface{}); ok {
|
||
// Один объект user
|
||
userInfo.ID = extractID(userObj["id"])
|
||
if email, ok := userObj["email"].(string); ok {
|
||
userInfo.Email = email
|
||
}
|
||
} else if usersArr, ok := result["user"].([]interface{}); ok && len(usersArr) > 0 {
|
||
// Массив users, берем первый
|
||
if userObj, ok := usersArr[0].(map[string]interface{}); ok {
|
||
userInfo.ID = extractID(userObj["id"])
|
||
if email, ok := userObj["email"].(string); ok {
|
||
userInfo.Email = email
|
||
}
|
||
}
|
||
} else {
|
||
log.Printf("Todoist API: user not found in response, available keys: %v", getMapKeys(result))
|
||
return userInfo, fmt.Errorf("user not found in response")
|
||
}
|
||
|
||
if userInfo.ID == 0 || userInfo.Email == "" {
|
||
log.Printf("Todoist API: incomplete user info: ID=%d, Email=%s", userInfo.ID, userInfo.Email)
|
||
return userInfo, fmt.Errorf("incomplete user info: ID=%d, Email=%s", userInfo.ID, userInfo.Email)
|
||
}
|
||
|
||
log.Printf("Todoist API: successfully got user info: ID=%d, Email=%s", userInfo.ID, userInfo.Email)
|
||
return userInfo, nil
|
||
}
|
||
|
||
// todoistOAuthConnectHandler инициирует OAuth flow
|
||
func (a *App) todoistOAuthConnectHandler(w http.ResponseWriter, r *http.Request) {
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
clientID := getEnv("TODOIST_CLIENT_ID", "")
|
||
clientSecret := getEnv("TODOIST_CLIENT_SECRET", "")
|
||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||
|
||
if clientID == "" || clientSecret == "" {
|
||
sendErrorWithCORS(w, "TODOIST_CLIENT_ID and TODOIST_CLIENT_SECRET must be configured", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if baseURL == "" {
|
||
sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback"
|
||
|
||
state, err := generateOAuthState(userID, a.jwtSecret)
|
||
if err != nil {
|
||
log.Printf("Todoist OAuth: failed to generate state: %v", err)
|
||
sendErrorWithCORS(w, "Failed to generate OAuth state", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
authURL := fmt.Sprintf(
|
||
"https://todoist.com/oauth/authorize?client_id=%s&scope=data:read_write&state=%s&redirect_uri=%s",
|
||
url.QueryEscape(clientID),
|
||
url.QueryEscape(state),
|
||
url.QueryEscape(redirectURI),
|
||
)
|
||
|
||
log.Printf("Todoist OAuth: returning auth URL for user_id=%d", userID)
|
||
|
||
// Возвращаем JSON с URL для редиректа (frontend сделает редирект)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"auth_url": authURL,
|
||
})
|
||
}
|
||
|
||
// todoistOAuthCallbackHandler обрабатывает OAuth callback
|
||
func (a *App) todoistOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||
frontendURL := getEnv("WEBHOOK_BASE_URL", "")
|
||
redirectSuccess := frontendURL + "/?integration=todoist&status=connected"
|
||
redirectError := frontendURL + "/?integration=todoist&status=error"
|
||
|
||
clientID := getEnv("TODOIST_CLIENT_ID", "")
|
||
clientSecret := getEnv("TODOIST_CLIENT_SECRET", "")
|
||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||
|
||
if clientID == "" || clientSecret == "" || baseURL == "" {
|
||
log.Printf("Todoist OAuth: missing configuration")
|
||
http.Redirect(w, r, redirectError+"&message=config_error", http.StatusTemporaryRedirect)
|
||
return
|
||
}
|
||
|
||
redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback"
|
||
|
||
// Проверяем state
|
||
state := r.URL.Query().Get("state")
|
||
userID, err := validateOAuthState(state, a.jwtSecret)
|
||
if err != nil {
|
||
log.Printf("Todoist OAuth: invalid state: %v", err)
|
||
http.Redirect(w, r, redirectError+"&message=invalid_state", http.StatusTemporaryRedirect)
|
||
return
|
||
}
|
||
|
||
// Получаем code
|
||
code := r.URL.Query().Get("code")
|
||
if code == "" {
|
||
log.Printf("Todoist OAuth: no code in callback")
|
||
http.Redirect(w, r, redirectError+"&message=no_code", http.StatusTemporaryRedirect)
|
||
return
|
||
}
|
||
|
||
// Обмениваем code на access_token
|
||
accessToken, err := exchangeCodeForToken(code, redirectURI, clientID, clientSecret)
|
||
if err != nil {
|
||
log.Printf("Todoist OAuth: token exchange failed: %v", err)
|
||
http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect)
|
||
return
|
||
}
|
||
|
||
// Получаем информацию о пользователе
|
||
todoistUser, err := getTodoistUserInfo(accessToken)
|
||
if err != nil {
|
||
log.Printf("Todoist OAuth: get user info failed: %v", err)
|
||
http.Redirect(w, r, redirectError+"&message=user_info_failed", http.StatusTemporaryRedirect)
|
||
return
|
||
}
|
||
|
||
log.Printf("Todoist OAuth: user_id=%d connected todoist_user_id=%d email=%s", userID, todoistUser.ID, todoistUser.Email)
|
||
|
||
// Сохраняем в БД
|
||
_, err = a.DB.Exec(`
|
||
INSERT INTO todoist_integrations (user_id, todoist_user_id, todoist_email, access_token)
|
||
VALUES ($1, $2, $3, $4)
|
||
ON CONFLICT (user_id) DO UPDATE SET
|
||
todoist_user_id = $2,
|
||
todoist_email = $3,
|
||
access_token = $4,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
`, userID, todoistUser.ID, todoistUser.Email, accessToken)
|
||
|
||
if err != nil {
|
||
log.Printf("Todoist OAuth: DB error: %v", err)
|
||
http.Redirect(w, r, redirectError+"&message=db_error", http.StatusTemporaryRedirect)
|
||
return
|
||
}
|
||
|
||
// Редирект на страницу интеграций
|
||
http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect)
|
||
}
|
||
|
||
// getTodoistStatusHandler возвращает статус подключения Todoist
|
||
func (a *App) getTodoistStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var todoistEmail sql.NullString
|
||
err := a.DB.QueryRow(`
|
||
SELECT todoist_email FROM todoist_integrations
|
||
WHERE user_id = $1 AND access_token IS NOT NULL
|
||
`, userID).Scan(&todoistEmail)
|
||
|
||
if err == sql.ErrNoRows || !todoistEmail.Valid {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"connected": false,
|
||
})
|
||
return
|
||
}
|
||
if err != nil {
|
||
sendErrorWithCORS(w, fmt.Sprintf("Failed to get status: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"connected": true,
|
||
"todoist_email": todoistEmail.String,
|
||
})
|
||
}
|
||
|
||
// ============================================
|
||
// Tasks handlers
|
||
// ============================================
|
||
|
||
// 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
|
||
}
|
||
|
||
// Запрос с получением всех необходимых данных для группировки и отображения
|
||
query := `
|
||
SELECT
|
||
t.id,
|
||
t.name,
|
||
t.completed,
|
||
t.last_completed_at,
|
||
t.next_show_at,
|
||
t.repetition_period::text,
|
||
t.repetition_date,
|
||
t.progression_base,
|
||
t.wishlist_id,
|
||
t.config_id,
|
||
t.reward_policy,
|
||
COALESCE((
|
||
SELECT COUNT(*)
|
||
FROM tasks st
|
||
WHERE st.parent_task_id = t.id AND st.deleted = FALSE
|
||
), 0) as subtasks_count,
|
||
COALESCE(
|
||
(SELECT array_agg(DISTINCT p.name) FILTER (WHERE p.name IS NOT NULL)
|
||
FROM reward_configs rc
|
||
JOIN projects p ON rc.project_id = p.id
|
||
WHERE rc.task_id = t.id),
|
||
ARRAY[]::text[]
|
||
) as project_names,
|
||
COALESCE(
|
||
(SELECT array_agg(DISTINCT p.name) FILTER (WHERE p.name IS NOT NULL)
|
||
FROM tasks st
|
||
JOIN reward_configs rc ON rc.task_id = st.id
|
||
JOIN projects p ON rc.project_id = p.id
|
||
WHERE st.parent_task_id = t.id AND st.deleted = FALSE),
|
||
ARRAY[]::text[]
|
||
) as subtask_project_names,
|
||
COALESCE(td.auto_complete, FALSE) as auto_complete
|
||
FROM tasks t
|
||
LEFT JOIN task_drafts td ON td.task_id = t.id AND td.user_id = $1
|
||
WHERE t.user_id = $1 AND t.parent_task_id IS NULL AND t.deleted = FALSE
|
||
ORDER BY
|
||
-- Сначала разделяем на невыполненные (0) и выполненные (1)
|
||
CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN 0 ELSE 1 END,
|
||
-- Для невыполненных: сортируем по completed DESC (больше завершений выше), затем по id ASC (раньше добавленные выше)
|
||
CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN -t.completed ELSE 0 END,
|
||
CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN t.id ELSE 0 END,
|
||
-- Для выполненных: сортируем по next_show_at ASC (ранние в начале), NULL значения в начале через COALESCE
|
||
CASE WHEN t.last_completed_at IS NOT NULL AND t.last_completed_at::date >= CURRENT_DATE
|
||
THEN COALESCE(t.next_show_at, '1970-01-01'::timestamp with time zone)
|
||
ELSE '1970-01-01'::timestamp with time zone
|
||
END
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID)
|
||
if err != nil {
|
||
log.Printf("Error querying tasks: %v", err)
|
||
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
|
||
var nextShowAt sql.NullString
|
||
var repetitionPeriod sql.NullString
|
||
var repetitionDate sql.NullString
|
||
var progressionBase sql.NullFloat64
|
||
var wishlistID sql.NullInt64
|
||
var configID sql.NullInt64
|
||
var rewardPolicy sql.NullString
|
||
var projectNames pq.StringArray
|
||
var subtaskProjectNames pq.StringArray
|
||
var autoComplete bool
|
||
|
||
err := rows.Scan(
|
||
&task.ID,
|
||
&task.Name,
|
||
&task.Completed,
|
||
&lastCompletedAt,
|
||
&nextShowAt,
|
||
&repetitionPeriod,
|
||
&repetitionDate,
|
||
&progressionBase,
|
||
&wishlistID,
|
||
&configID,
|
||
&rewardPolicy,
|
||
&task.SubtasksCount,
|
||
&projectNames,
|
||
&subtaskProjectNames,
|
||
&autoComplete,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning task: %v", err)
|
||
continue
|
||
}
|
||
|
||
if lastCompletedAt.Valid {
|
||
task.LastCompletedAt = &lastCompletedAt.String
|
||
}
|
||
if nextShowAt.Valid {
|
||
task.NextShowAt = &nextShowAt.String
|
||
}
|
||
if repetitionPeriod.Valid {
|
||
task.RepetitionPeriod = &repetitionPeriod.String
|
||
}
|
||
if repetitionDate.Valid {
|
||
task.RepetitionDate = &repetitionDate.String
|
||
}
|
||
if progressionBase.Valid {
|
||
task.HasProgression = true
|
||
task.ProgressionBase = &progressionBase.Float64
|
||
} else {
|
||
task.HasProgression = false
|
||
}
|
||
if wishlistID.Valid {
|
||
wishlistIDInt := int(wishlistID.Int64)
|
||
task.WishlistID = &wishlistIDInt
|
||
}
|
||
if configID.Valid {
|
||
configIDInt := int(configID.Int64)
|
||
task.ConfigID = &configIDInt
|
||
}
|
||
if rewardPolicy.Valid {
|
||
task.RewardPolicy = &rewardPolicy.String
|
||
}
|
||
task.AutoComplete = autoComplete
|
||
|
||
// Объединяем проекты из основной задачи и подзадач
|
||
allProjects := make(map[string]bool)
|
||
for _, pn := range projectNames {
|
||
if pn != "" {
|
||
allProjects[pn] = true
|
||
}
|
||
}
|
||
for _, pn := range subtaskProjectNames {
|
||
if pn != "" {
|
||
allProjects[pn] = true
|
||
}
|
||
}
|
||
|
||
task.ProjectNames = make([]string, 0, len(allProjects))
|
||
for pn := range allProjects {
|
||
task.ProjectNames = append(task.ProjectNames, pn)
|
||
}
|
||
|
||
tasks = append(tasks, task)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(tasks)
|
||
}
|
||
|
||
// getTaskDetailHandler возвращает детальную информацию о задаче
|
||
func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
taskID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Получаем основную задачу
|
||
var task Task
|
||
var rewardMessage sql.NullString
|
||
var progressionBase sql.NullFloat64
|
||
var lastCompletedAt sql.NullString
|
||
var nextShowAt sql.NullString
|
||
var repetitionPeriod sql.NullString
|
||
var repetitionDate sql.NullString
|
||
var wishlistID sql.NullInt64
|
||
var configID sql.NullInt64
|
||
var rewardPolicy sql.NullString
|
||
|
||
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
|
||
var repetitionPeriodStr string
|
||
var repetitionDateStr string
|
||
err = a.DB.QueryRow(`
|
||
SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
|
||
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period,
|
||
COALESCE(repetition_date, '') as repetition_date,
|
||
wishlist_id,
|
||
config_id,
|
||
reward_policy
|
||
FROM tasks
|
||
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
||
`, taskID, userID).Scan(
|
||
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &rewardPolicy,
|
||
)
|
||
|
||
log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
|
||
|
||
// Преобразуем в sql.NullString для совместимости
|
||
if repetitionPeriodStr != "" {
|
||
repetitionPeriod = sql.NullString{String: repetitionPeriodStr, Valid: true}
|
||
} else {
|
||
repetitionPeriod = sql.NullString{Valid: false}
|
||
}
|
||
if repetitionDateStr != "" {
|
||
repetitionDate = sql.NullString{String: repetitionDateStr, Valid: true}
|
||
} else {
|
||
repetitionDate = sql.NullString{Valid: false}
|
||
}
|
||
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error querying task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error querying task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if rewardMessage.Valid {
|
||
task.RewardMessage = &rewardMessage.String
|
||
}
|
||
if progressionBase.Valid {
|
||
task.ProgressionBase = &progressionBase.Float64
|
||
}
|
||
if lastCompletedAt.Valid {
|
||
task.LastCompletedAt = &lastCompletedAt.String
|
||
}
|
||
if nextShowAt.Valid {
|
||
task.NextShowAt = &nextShowAt.String
|
||
}
|
||
if repetitionPeriod.Valid && repetitionPeriod.String != "" {
|
||
task.RepetitionPeriod = &repetitionPeriod.String
|
||
log.Printf("Task %d has repetition_period: %s", task.ID, repetitionPeriod.String)
|
||
} else {
|
||
log.Printf("Task %d has no repetition_period (Valid: %v, String: '%s')", task.ID, repetitionPeriod.Valid, repetitionPeriod.String)
|
||
}
|
||
if repetitionDate.Valid && repetitionDate.String != "" {
|
||
task.RepetitionDate = &repetitionDate.String
|
||
log.Printf("Task %d has repetition_date: %s", task.ID, repetitionDate.String)
|
||
}
|
||
if wishlistID.Valid {
|
||
wishlistIDInt := int(wishlistID.Int64)
|
||
task.WishlistID = &wishlistIDInt
|
||
}
|
||
if configID.Valid {
|
||
configIDInt := int(configID.Int64)
|
||
task.ConfigID = &configIDInt
|
||
}
|
||
if rewardPolicy.Valid {
|
||
task.RewardPolicy = &rewardPolicy.String
|
||
}
|
||
|
||
// Получаем награды основной задачи
|
||
rewards := make([]Reward, 0)
|
||
rewardRows, err := a.DB.Query(`
|
||
SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression
|
||
FROM reward_configs rc
|
||
JOIN projects p ON rc.project_id = p.id
|
||
WHERE rc.task_id = $1
|
||
ORDER BY rc.position
|
||
`, taskID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error querying rewards: %v", err)
|
||
} else {
|
||
defer rewardRows.Close()
|
||
for rewardRows.Next() {
|
||
var reward Reward
|
||
err := rewardRows.Scan(&reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression)
|
||
if err != nil {
|
||
log.Printf("Error scanning reward: %v", err)
|
||
continue
|
||
}
|
||
rewards = append(rewards, reward)
|
||
}
|
||
}
|
||
|
||
// Получаем подзадачи
|
||
subtasks := make([]Subtask, 0)
|
||
subtaskRows, err := a.DB.Query(`
|
||
SELECT id, name, completed, last_completed_at, reward_message, progression_base, position
|
||
FROM tasks
|
||
WHERE parent_task_id = $1 AND deleted = FALSE
|
||
ORDER BY COALESCE(position, id)
|
||
`, taskID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error querying subtasks: %v", err)
|
||
} else {
|
||
defer subtaskRows.Close()
|
||
subtaskMap := make(map[int]*Subtask)
|
||
subtaskIDs := make([]int, 0)
|
||
|
||
for subtaskRows.Next() {
|
||
var subtaskTask Task
|
||
var subtaskRewardMessage sql.NullString
|
||
var subtaskProgressionBase sql.NullFloat64
|
||
var subtaskLastCompletedAt sql.NullString
|
||
var subtaskPosition sql.NullInt64
|
||
|
||
err := subtaskRows.Scan(
|
||
&subtaskTask.ID, &subtaskTask.Name, &subtaskTask.Completed,
|
||
&subtaskLastCompletedAt, &subtaskRewardMessage, &subtaskProgressionBase,
|
||
&subtaskPosition,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning subtask: %v", err)
|
||
continue
|
||
}
|
||
|
||
if subtaskRewardMessage.Valid {
|
||
subtaskTask.RewardMessage = &subtaskRewardMessage.String
|
||
}
|
||
if subtaskProgressionBase.Valid {
|
||
subtaskTask.ProgressionBase = &subtaskProgressionBase.Float64
|
||
}
|
||
if subtaskLastCompletedAt.Valid {
|
||
subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String
|
||
}
|
||
if subtaskPosition.Valid {
|
||
pos := int(subtaskPosition.Int64)
|
||
subtaskTask.Position = &pos
|
||
}
|
||
|
||
subtaskIDs = append(subtaskIDs, subtaskTask.ID)
|
||
subtask := Subtask{
|
||
Task: subtaskTask,
|
||
Rewards: make([]Reward, 0),
|
||
}
|
||
subtaskMap[subtaskTask.ID] = &subtask
|
||
}
|
||
|
||
// Загружаем все награды всех подзадач одним запросом
|
||
if len(subtaskIDs) > 0 {
|
||
// Используем параметризованный запрос с ANY(ARRAY[...])
|
||
query := `
|
||
SELECT rc.task_id, rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression
|
||
FROM reward_configs rc
|
||
JOIN projects p ON rc.project_id = p.id
|
||
WHERE rc.task_id = ANY($1::int[])
|
||
ORDER BY rc.task_id, rc.position
|
||
`
|
||
|
||
subtaskRewardRows, err := a.DB.Query(query, pq.Array(subtaskIDs))
|
||
if err != nil {
|
||
log.Printf("Error querying subtask rewards: %v", err)
|
||
} else {
|
||
defer subtaskRewardRows.Close()
|
||
for subtaskRewardRows.Next() {
|
||
var taskID int
|
||
var reward Reward
|
||
err := subtaskRewardRows.Scan(&taskID, &reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression)
|
||
if err != nil {
|
||
log.Printf("Error scanning subtask reward: %v", err)
|
||
continue
|
||
}
|
||
if subtask, exists := subtaskMap[taskID]; exists {
|
||
subtask.Rewards = append(subtask.Rewards, reward)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Преобразуем map в slice, сохраняя порядок по ID
|
||
for _, id := range subtaskIDs {
|
||
if subtask, exists := subtaskMap[id]; exists {
|
||
subtasks = append(subtasks, *subtask)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Инициализируем auto_complete значением по умолчанию
|
||
task.AutoComplete = false
|
||
|
||
// Загружаем данные из драфта, если он существует
|
||
var draftProgressionValue sql.NullFloat64
|
||
var draftAutoComplete sql.NullBool
|
||
var draftProgressionValuePtr *float64
|
||
var draftSubtasks []DraftSubtask
|
||
err = a.DB.QueryRow(`
|
||
SELECT progression_value, auto_complete
|
||
FROM task_drafts
|
||
WHERE task_id = $1 AND user_id = $2
|
||
`, taskID, userID).Scan(&draftProgressionValue, &draftAutoComplete)
|
||
|
||
if err == nil {
|
||
// Драфт существует, загружаем данные
|
||
if draftProgressionValue.Valid {
|
||
draftProgressionValuePtr = &draftProgressionValue.Float64
|
||
}
|
||
// Устанавливаем auto_complete из драфта (если Valid, иначе остается false)
|
||
if draftAutoComplete.Valid {
|
||
task.AutoComplete = draftAutoComplete.Bool
|
||
log.Printf("Task %d: auto_complete set to %v from draft", taskID, task.AutoComplete)
|
||
} else {
|
||
log.Printf("Task %d: draft exists but auto_complete is NULL, keeping default false", taskID)
|
||
}
|
||
|
||
// Загружаем подзадачи из драфта
|
||
draftSubtaskRows, err := a.DB.Query(`
|
||
SELECT subtask_id
|
||
FROM task_draft_subtasks
|
||
WHERE task_draft_id = (SELECT id FROM task_drafts WHERE task_id = $1 AND user_id = $2)
|
||
`, taskID, userID)
|
||
if err == nil {
|
||
defer draftSubtaskRows.Close()
|
||
draftSubtasks = make([]DraftSubtask, 0)
|
||
validSubtaskIDs := make(map[int]bool)
|
||
// Создаем map валидных подзадач для фильтрации
|
||
for _, subtask := range subtasks {
|
||
validSubtaskIDs[subtask.Task.ID] = true
|
||
}
|
||
|
||
for draftSubtaskRows.Next() {
|
||
var subtaskID int
|
||
if err := draftSubtaskRows.Scan(&subtaskID); err == nil {
|
||
// Игнорируем подзадачи, которых больше нет в основной задаче
|
||
if validSubtaskIDs[subtaskID] {
|
||
draftSubtasks = append(draftSubtasks, DraftSubtask{
|
||
SubtaskID: subtaskID,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
} else if err != sql.ErrNoRows {
|
||
log.Printf("Error loading draft subtasks for task %d: %v", taskID, err)
|
||
}
|
||
} else if err != sql.ErrNoRows {
|
||
log.Printf("Error loading draft for task %d: %v", taskID, err)
|
||
} else {
|
||
log.Printf("Task %d: no draft found, auto_complete remains false", taskID)
|
||
}
|
||
// Если драфта нет (err == sql.ErrNoRows), auto_complete остается false
|
||
log.Printf("Task %d: final auto_complete value = %v", taskID, task.AutoComplete)
|
||
|
||
response := TaskDetail{
|
||
Task: task,
|
||
Rewards: rewards,
|
||
Subtasks: subtasks,
|
||
}
|
||
|
||
// Устанавливаем DraftProgressionValue если он был загружен
|
||
if draftProgressionValuePtr != nil {
|
||
response.DraftProgressionValue = draftProgressionValuePtr
|
||
}
|
||
|
||
// Устанавливаем DraftSubtasks если они были загружены
|
||
if len(draftSubtasks) > 0 {
|
||
response.DraftSubtasks = draftSubtasks
|
||
}
|
||
|
||
// Если задача связана с wishlist, загружаем базовую информацию о wishlist
|
||
if wishlistID.Valid {
|
||
var wishlistName string
|
||
err := a.DB.QueryRow(`
|
||
SELECT name
|
||
FROM wishlist_items
|
||
WHERE id = $1 AND deleted = FALSE
|
||
`, wishlistID.Int64).Scan(&wishlistName)
|
||
|
||
if err == nil {
|
||
unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID)
|
||
if err != nil {
|
||
log.Printf("Error checking wishlist unlock status: %v", err)
|
||
unlocked = false
|
||
}
|
||
|
||
response.WishlistInfo = &WishlistInfo{
|
||
ID: int(wishlistID.Int64),
|
||
Name: wishlistName,
|
||
Unlocked: unlocked,
|
||
}
|
||
} else if err != sql.ErrNoRows {
|
||
log.Printf("Error loading wishlist info for task %d: %v", taskID, err)
|
||
}
|
||
}
|
||
|
||
// Если задача - тест (есть config_id), загружаем данные конфигурации
|
||
if configID.Valid {
|
||
var wordsCount int
|
||
var maxCards sql.NullInt64
|
||
err := a.DB.QueryRow(`
|
||
SELECT words_count, max_cards
|
||
FROM configs
|
||
WHERE id = $1
|
||
`, configID.Int64).Scan(&wordsCount, &maxCards)
|
||
|
||
if err == nil {
|
||
response.WordsCount = &wordsCount
|
||
if maxCards.Valid {
|
||
maxCardsInt := int(maxCards.Int64)
|
||
response.MaxCards = &maxCardsInt
|
||
}
|
||
|
||
// Загружаем связанные словари
|
||
dictRows, err := a.DB.Query(`
|
||
SELECT dictionary_id
|
||
FROM config_dictionaries
|
||
WHERE config_id = $1
|
||
`, configID.Int64)
|
||
if err == nil {
|
||
defer dictRows.Close()
|
||
dictionaryIDs := make([]int, 0)
|
||
for dictRows.Next() {
|
||
var dictID int
|
||
if err := dictRows.Scan(&dictID); err == nil {
|
||
dictionaryIDs = append(dictionaryIDs, dictID)
|
||
}
|
||
}
|
||
if len(dictionaryIDs) > 0 {
|
||
response.DictionaryIDs = dictionaryIDs
|
||
}
|
||
}
|
||
} else {
|
||
log.Printf("Error loading config for task %d: %v", taskID, err)
|
||
}
|
||
}
|
||
|
||
log.Printf("Task %d: Sending response with auto_complete = %v (task.AutoComplete = %v)", taskID, response.Task.AutoComplete, task.AutoComplete)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
// findProjectByName находит проект по имени (регистронезависимо) или возвращает ошибку
|
||
func (a *App) findProjectByName(projectName string, userID int) (int, error) {
|
||
var projectID int
|
||
err := a.DB.QueryRow(`
|
||
SELECT id FROM projects
|
||
WHERE LOWER(name) = LOWER($1) AND user_id = $2 AND deleted = FALSE
|
||
`, projectName, userID).Scan(&projectID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
return 0, fmt.Errorf("project not found: %s", projectName)
|
||
}
|
||
if err != nil {
|
||
return 0, fmt.Errorf("error finding project: %w", err)
|
||
}
|
||
|
||
return projectID, nil
|
||
}
|
||
|
||
// findProjectByNameTx находит проект по имени в транзакции
|
||
func (a *App) findProjectByNameTx(tx *sql.Tx, projectName string, userID int) (int, error) {
|
||
var projectID int
|
||
err := tx.QueryRow(`
|
||
SELECT id FROM projects
|
||
WHERE LOWER(name) = LOWER($1) AND user_id = $2 AND deleted = FALSE
|
||
`, projectName, userID).Scan(&projectID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
return 0, fmt.Errorf("project not found: %s", projectName)
|
||
}
|
||
if err != nil {
|
||
return 0, fmt.Errorf("error finding project: %w", err)
|
||
}
|
||
|
||
return projectID, nil
|
||
}
|
||
|
||
// createTaskHandler создает новую задачу
|
||
func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req TaskRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding task request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Валидация
|
||
if len(strings.TrimSpace(req.Name)) < 1 {
|
||
sendErrorWithCORS(w, "Task name is required and must be at least 1 character", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем, что все rewards имеют project_name
|
||
for _, reward := range req.Rewards {
|
||
if strings.TrimSpace(reward.ProjectName) == "" {
|
||
sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Валидация wishlist_id: если указан, проверяем что желание существует и пользователь имеет доступ
|
||
var wishlistName string
|
||
if req.WishlistID != nil {
|
||
var wishlistOwnerID int
|
||
var authorID sql.NullInt64
|
||
var boardID sql.NullInt64
|
||
err := a.DB.QueryRow(`
|
||
SELECT user_id, name, author_id, board_id FROM wishlist_items
|
||
WHERE id = $1 AND deleted = FALSE
|
||
`, *req.WishlistID).Scan(&wishlistOwnerID, &wishlistName, &authorID, &boardID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking wishlist item: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist item: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
hasAccess := wishlistOwnerID == userID
|
||
// Проверяем, является ли пользователь автором желания
|
||
if !hasAccess && authorID.Valid && authorID.Int64 == int64(userID) {
|
||
hasAccess = true
|
||
}
|
||
// Проверяем доступ к доске, если желание принадлежит доске
|
||
if !hasAccess && boardID.Valid {
|
||
var boardOwnerID int
|
||
err := a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID.Int64).Scan(&boardOwnerID)
|
||
if err == nil && boardOwnerID == userID {
|
||
hasAccess = true
|
||
} else if err == nil {
|
||
var isMember bool
|
||
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, boardID.Int64, userID).Scan(&isMember)
|
||
if isMember {
|
||
hasAccess = true
|
||
}
|
||
}
|
||
}
|
||
|
||
if !hasAccess {
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
// Проверяем, что нет другой активной (не удаленной и не выполненной) задачи с таким wishlist_id для этого пользователя
|
||
// Если задача была выполнена (completed > 0) или удалена, можно создать новую
|
||
var existingTaskID int
|
||
var existingTaskCompleted int
|
||
err = a.DB.QueryRow(`
|
||
SELECT id, completed FROM tasks
|
||
WHERE wishlist_id = $1 AND user_id = $2 AND deleted = FALSE
|
||
`, *req.WishlistID, userID).Scan(&existingTaskID, &existingTaskCompleted)
|
||
|
||
if err != sql.ErrNoRows {
|
||
if err != nil {
|
||
log.Printf("Error checking existing task for wishlist: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking existing task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
// Если задача была выполнена (completed > 0), можно создать новую
|
||
if existingTaskCompleted > 0 {
|
||
log.Printf("Existing task %d for wishlist %d was completed (%d times), marking as deleted and allowing new task creation", existingTaskID, *req.WishlistID, existingTaskCompleted)
|
||
// Помечаем старую выполненную задачу как удаленную, чтобы избежать конфликта с уникальным индексом
|
||
_, err = a.DB.Exec(`
|
||
UPDATE tasks
|
||
SET deleted = TRUE
|
||
WHERE id = $1
|
||
`, existingTaskID)
|
||
if err != nil {
|
||
log.Printf("Error marking existing completed task as deleted: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error marking existing task as deleted: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
} else {
|
||
sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Если название задачи не указано или пустое, используем название желания
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
req.Name = wishlistName
|
||
}
|
||
|
||
// Если сообщение награды не указано или пустое, устанавливаем "Выполнить желание: {TITLE}"
|
||
if req.RewardMessage == nil || strings.TrimSpace(*req.RewardMessage) == "" {
|
||
rewardMsg := fmt.Sprintf("Выполнить желание: %s", wishlistName)
|
||
req.RewardMessage = &rewardMsg
|
||
}
|
||
|
||
// Задачи, привязанные к желанию, не могут быть периодическими
|
||
if (req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "") ||
|
||
(req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "") {
|
||
// Проверяем, что это не бесконечная задача (оба поля = 0)
|
||
isPeriodZero := req.RepetitionPeriod != nil && (strings.TrimSpace(*req.RepetitionPeriod) == "0 day" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionPeriod), "0 "))
|
||
isDateZero := req.RepetitionDate != nil && (strings.TrimSpace(*req.RepetitionDate) == "0 week" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionDate), "0 "))
|
||
if !isPeriodZero || !isDateZero {
|
||
sendErrorWithCORS(w, "Tasks linked to wishlist items cannot be periodic", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Задачи, привязанные к желанию, не могут иметь прогрессию
|
||
if req.ProgressionBase != nil {
|
||
sendErrorWithCORS(w, "Tasks linked to wishlist items cannot have progression", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Начинаем транзакцию
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Создаем основную задачу
|
||
var taskID int
|
||
var rewardMessage sql.NullString
|
||
var progressionBase sql.NullFloat64
|
||
var repetitionPeriod sql.NullString
|
||
var repetitionDate sql.NullString
|
||
if req.RewardMessage != nil {
|
||
rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true}
|
||
}
|
||
if req.ProgressionBase != nil {
|
||
progressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true}
|
||
}
|
||
if req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "" {
|
||
repetitionPeriod = sql.NullString{String: strings.TrimSpace(*req.RepetitionPeriod), Valid: true}
|
||
log.Printf("Creating task with repetition_period: %s", repetitionPeriod.String)
|
||
} else {
|
||
log.Printf("Creating task without repetition_period (req.RepetitionPeriod: %v)", req.RepetitionPeriod)
|
||
}
|
||
if req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "" {
|
||
repetitionDate = sql.NullString{String: strings.TrimSpace(*req.RepetitionDate), Valid: true}
|
||
log.Printf("Creating task with repetition_date: %s", repetitionDate.String)
|
||
}
|
||
|
||
// Используем CAST для преобразования строки в INTERVAL
|
||
var repetitionPeriodValue interface{}
|
||
if repetitionPeriod.Valid {
|
||
repetitionPeriodValue = repetitionPeriod.String
|
||
} else {
|
||
repetitionPeriodValue = nil
|
||
}
|
||
|
||
// Подготовка wishlist_id для INSERT
|
||
var wishlistIDValue interface{}
|
||
if req.WishlistID != nil {
|
||
wishlistIDValue = *req.WishlistID
|
||
log.Printf("Creating task with wishlist_id: %d", *req.WishlistID)
|
||
} else {
|
||
wishlistIDValue = nil
|
||
log.Printf("Creating task without wishlist_id")
|
||
}
|
||
|
||
// Подготовка reward_policy: если задача связана с желанием и политика не указана, используем "personal" по умолчанию
|
||
var rewardPolicyValue interface{}
|
||
if req.WishlistID != nil {
|
||
if req.RewardPolicy != nil && (*req.RewardPolicy == "personal" || *req.RewardPolicy == "general") {
|
||
rewardPolicyValue = *req.RewardPolicy
|
||
} else {
|
||
rewardPolicyValue = "personal" // Значение по умолчанию для задач, связанных с желаниями
|
||
}
|
||
} else {
|
||
rewardPolicyValue = nil // NULL для задач, не связанных с желаниями
|
||
}
|
||
|
||
// Используем условный SQL для обработки NULL значений
|
||
var insertSQL string
|
||
var insertArgs []interface{}
|
||
if repetitionPeriod.Valid {
|
||
// Для repetition_period выставляем сегодняшнюю дату
|
||
|
||
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
||
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||
loc, err := time.LoadLocation(timezoneStr)
|
||
if err != nil {
|
||
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
|
||
loc = time.UTC
|
||
}
|
||
|
||
now := time.Now().In(loc)
|
||
insertSQL = `
|
||
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy)
|
||
VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE, $7, $8)
|
||
RETURNING id
|
||
`
|
||
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now, wishlistIDValue, rewardPolicyValue}
|
||
} else if repetitionDate.Valid {
|
||
// Вычисляем next_show_at для задачи с repetition_date
|
||
|
||
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
||
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||
loc, err := time.LoadLocation(timezoneStr)
|
||
if err != nil {
|
||
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
|
||
loc = time.UTC
|
||
}
|
||
|
||
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc))
|
||
if nextShowAt != nil {
|
||
insertSQL = `
|
||
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy)
|
||
VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE, $7, $8)
|
||
RETURNING id
|
||
`
|
||
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, wishlistIDValue, rewardPolicyValue}
|
||
} else {
|
||
insertSQL = `
|
||
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)
|
||
RETURNING id
|
||
`
|
||
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, wishlistIDValue, rewardPolicyValue}
|
||
}
|
||
} else {
|
||
insertSQL = `
|
||
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)
|
||
RETURNING id
|
||
`
|
||
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, wishlistIDValue, rewardPolicyValue}
|
||
}
|
||
|
||
err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating task: %v", err)
|
||
// Проверяем, не является ли это ошибкой уникального индекса
|
||
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
|
||
sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest)
|
||
return
|
||
}
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Создаем награды для основной задачи
|
||
for _, rewardReq := range req.Rewards {
|
||
projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID)
|
||
if err != nil {
|
||
log.Printf("Error finding project %s: %v", rewardReq.ProjectName, err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
_, err = tx.Exec(`
|
||
INSERT INTO reward_configs (position, task_id, project_id, value, use_progression)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
`, rewardReq.Position, taskID, projectID, rewardReq.Value, rewardReq.UseProgression)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating reward: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating reward: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Создаем подзадачи
|
||
for index, subtaskReq := range req.Subtasks {
|
||
var subtaskName sql.NullString
|
||
var subtaskRewardMessage sql.NullString
|
||
var subtaskProgressionBase sql.NullFloat64
|
||
var subtaskPosition sql.NullInt64
|
||
|
||
if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" {
|
||
subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true}
|
||
}
|
||
if subtaskReq.RewardMessage != nil {
|
||
subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true}
|
||
}
|
||
if req.ProgressionBase != nil {
|
||
subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true}
|
||
}
|
||
// Используем position из запроса, если указан, иначе используем индекс в массиве
|
||
if subtaskReq.Position != nil {
|
||
subtaskPosition = sql.NullInt64{Int64: int64(*subtaskReq.Position), Valid: true}
|
||
} else {
|
||
subtaskPosition = sql.NullInt64{Int64: int64(index), Valid: true}
|
||
}
|
||
|
||
var subtaskID int
|
||
err = tx.QueryRow(`
|
||
INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted, position)
|
||
VALUES ($1, $2, $3, $4, $5, 0, FALSE, $6)
|
||
RETURNING id
|
||
`, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition).Scan(&subtaskID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating subtask: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Создаем награды для подзадачи
|
||
for _, rewardReq := range subtaskReq.Rewards {
|
||
if strings.TrimSpace(rewardReq.ProjectName) == "" {
|
||
sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID)
|
||
if err != nil {
|
||
log.Printf("Error finding project %s for subtask: %v", rewardReq.ProjectName, err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
_, err = tx.Exec(`
|
||
INSERT INTO reward_configs (position, task_id, project_id, value, use_progression)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
`, rewardReq.Position, subtaskID, projectID, rewardReq.Value, rewardReq.UseProgression)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating subtask reward: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если это тест, создаем конфигурацию
|
||
if req.IsTest {
|
||
// Валидация: для теста должны быть указаны words_count и хотя бы один словарь
|
||
if req.WordsCount == nil || *req.WordsCount < 1 {
|
||
sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if len(req.DictionaryIDs) == 0 {
|
||
sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Создаем конфигурацию теста
|
||
var configID int
|
||
if req.MaxCards != nil {
|
||
err = tx.QueryRow(`
|
||
INSERT INTO configs (user_id, words_count, max_cards)
|
||
VALUES ($1, $2, $3)
|
||
RETURNING id
|
||
`, userID, *req.WordsCount, *req.MaxCards).Scan(&configID)
|
||
} else {
|
||
err = tx.QueryRow(`
|
||
INSERT INTO configs (user_id, words_count)
|
||
VALUES ($1, $2)
|
||
RETURNING id
|
||
`, userID, *req.WordsCount).Scan(&configID)
|
||
}
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating config: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Связываем конфигурацию со словарями
|
||
for _, dictID := range req.DictionaryIDs {
|
||
_, err = tx.Exec(`
|
||
INSERT INTO config_dictionaries (config_id, dictionary_id)
|
||
VALUES ($1, $2)
|
||
`, configID, dictID)
|
||
if err != nil {
|
||
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Обновляем задачу, привязывая config_id
|
||
_, err = tx.Exec(`
|
||
UPDATE tasks SET config_id = $1 WHERE id = $2
|
||
`, configID, taskID)
|
||
if err != nil {
|
||
log.Printf("Error linking config to task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
log.Printf("Created test config %d for task %d", configID, taskID)
|
||
}
|
||
|
||
// Коммитим транзакцию
|
||
if 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
|
||
var createdRepetitionDate sql.NullString
|
||
err = a.DB.QueryRow(`
|
||
SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text, repetition_date
|
||
FROM tasks
|
||
WHERE id = $1
|
||
`, taskID).Scan(
|
||
&createdTask.ID, &createdTask.Name, &createdTask.Completed,
|
||
&lastCompletedAt, &rewardMessage, &progressionBase, &createdRepetitionPeriod, &createdRepetitionDate,
|
||
)
|
||
|
||
if err != nil {
|
||
log.Printf("Error fetching created task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error fetching created task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if rewardMessage.Valid {
|
||
createdTask.RewardMessage = &rewardMessage.String
|
||
}
|
||
if progressionBase.Valid {
|
||
createdTask.ProgressionBase = &progressionBase.Float64
|
||
}
|
||
if lastCompletedAt.Valid {
|
||
createdTask.LastCompletedAt = &lastCompletedAt.String
|
||
}
|
||
if createdRepetitionPeriod.Valid {
|
||
createdTask.RepetitionPeriod = &createdRepetitionPeriod.String
|
||
}
|
||
if createdRepetitionDate.Valid {
|
||
createdTask.RepetitionDate = &createdRepetitionDate.String
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
json.NewEncoder(w).Encode(createdTask)
|
||
}
|
||
|
||
// updateTaskHandler обновляет существующую задачу
|
||
func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
taskID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем владельца
|
||
var ownerID int
|
||
err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1", taskID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows || ownerID != userID {
|
||
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking task ownership: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
var req TaskRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding task request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Валидация
|
||
if len(strings.TrimSpace(req.Name)) < 1 {
|
||
sendErrorWithCORS(w, "Task name is required and must be at least 1 character", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем, что все rewards имеют project_name
|
||
for _, reward := range req.Rewards {
|
||
if strings.TrimSpace(reward.ProjectName) == "" {
|
||
sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Обработка wishlist_id: можно только отвязать (установить в NULL), нельзя привязать
|
||
// Если req.WishlistID == nil, значит пользователь хочет отвязать (или не трогать)
|
||
// Если req.WishlistID != nil, игнорируем (нельзя привязать при редактировании)
|
||
// Получаем текущий wishlist_id задачи
|
||
var currentWishlistID sql.NullInt64
|
||
err = a.DB.QueryRow("SELECT wishlist_id FROM tasks WHERE id = $1", taskID).Scan(¤tWishlistID)
|
||
if err != nil {
|
||
log.Printf("Error getting current wishlist_id: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Определяем новое значение wishlist_id
|
||
// Если задача была привязана и req.WishlistID == nil, значит отвязываем
|
||
// Если req.WishlistID != nil, игнорируем (нельзя привязать)
|
||
var newWishlistID interface{}
|
||
if currentWishlistID.Valid && req.WishlistID == nil {
|
||
// Отвязываем от желания
|
||
newWishlistID = nil
|
||
} else if currentWishlistID.Valid {
|
||
// Оставляем текущее значение (нельзя привязать)
|
||
newWishlistID = currentWishlistID.Int64
|
||
} else {
|
||
// Задача не была привязана, оставляем NULL
|
||
newWishlistID = nil
|
||
}
|
||
|
||
// Если задача привязана к желанию, не позволяем устанавливать повторения и прогрессию
|
||
if currentWishlistID.Valid {
|
||
if (req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "") ||
|
||
(req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "") {
|
||
// Проверяем, что это не бесконечная задача (оба поля = 0)
|
||
isPeriodZero := req.RepetitionPeriod != nil && (strings.TrimSpace(*req.RepetitionPeriod) == "0 day" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionPeriod), "0 "))
|
||
isDateZero := req.RepetitionDate != nil && (strings.TrimSpace(*req.RepetitionDate) == "0 week" || strings.HasPrefix(strings.TrimSpace(*req.RepetitionDate), "0 "))
|
||
if !isPeriodZero || !isDateZero {
|
||
sendErrorWithCORS(w, "Tasks linked to wishlist items cannot be periodic", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Задачи, привязанные к желанию, не могут иметь прогрессию
|
||
if req.ProgressionBase != nil {
|
||
sendErrorWithCORS(w, "Tasks linked to wishlist items cannot have progression", http.StatusBadRequest)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Начинаем транзакцию
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Обновляем основную задачу
|
||
var rewardMessage sql.NullString
|
||
var progressionBase sql.NullFloat64
|
||
var repetitionPeriod sql.NullString
|
||
var repetitionDate sql.NullString
|
||
if req.RewardMessage != nil {
|
||
rewardMessage = sql.NullString{String: *req.RewardMessage, Valid: true}
|
||
}
|
||
if req.ProgressionBase != nil {
|
||
progressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true}
|
||
}
|
||
if req.RepetitionPeriod != nil && strings.TrimSpace(*req.RepetitionPeriod) != "" {
|
||
repetitionPeriod = sql.NullString{String: strings.TrimSpace(*req.RepetitionPeriod), Valid: true}
|
||
log.Printf("Updating task %d with repetition_period: %s", taskID, repetitionPeriod.String)
|
||
} else {
|
||
log.Printf("Updating task %d without repetition_period (req.RepetitionPeriod: %v)", taskID, req.RepetitionPeriod)
|
||
}
|
||
if req.RepetitionDate != nil && strings.TrimSpace(*req.RepetitionDate) != "" {
|
||
repetitionDate = sql.NullString{String: strings.TrimSpace(*req.RepetitionDate), Valid: true}
|
||
log.Printf("Updating task %d with repetition_date: %s", taskID, repetitionDate.String)
|
||
}
|
||
|
||
// Подготовка reward_policy: если задача связана с желанием и политика не указана, используем "personal" по умолчанию
|
||
var rewardPolicyValue interface{}
|
||
if newWishlistID != nil {
|
||
// Если reward_policy явно указан в запросе, используем его
|
||
if req.RewardPolicy != nil && (*req.RewardPolicy == "personal" || *req.RewardPolicy == "general") {
|
||
rewardPolicyValue = *req.RewardPolicy
|
||
} else if req.RewardPolicy == nil {
|
||
// Если reward_policy не указан в запросе (undefined), сохраняем текущее значение из БД
|
||
// Это важно для случаев, когда обновляются другие поля, но reward_policy не должен меняться
|
||
var currentRewardPolicy sql.NullString
|
||
err = a.DB.QueryRow("SELECT reward_policy FROM tasks WHERE id = $1", taskID).Scan(¤tRewardPolicy)
|
||
if err == nil && currentRewardPolicy.Valid {
|
||
rewardPolicyValue = currentRewardPolicy.String
|
||
} else {
|
||
// Если в БД нет значения, используем "personal" по умолчанию
|
||
rewardPolicyValue = "personal"
|
||
}
|
||
}
|
||
} else {
|
||
rewardPolicyValue = nil // NULL для задач, не связанных с желаниями
|
||
}
|
||
|
||
// Используем условный SQL для обработки NULL значений
|
||
var updateSQL string
|
||
var updateArgs []interface{}
|
||
|
||
// Получаем часовой пояс из переменной окружения (по умолчанию 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
|
||
}
|
||
|
||
if repetitionPeriod.Valid {
|
||
// Для repetition_period выставляем сегодняшнюю дату
|
||
now := time.Now().In(loc)
|
||
updateSQL = `
|
||
UPDATE tasks
|
||
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
|
||
`
|
||
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, newWishlistID, rewardPolicyValue, taskID}
|
||
} else if repetitionDate.Valid {
|
||
// Вычисляем next_show_at для задачи с repetition_date
|
||
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc))
|
||
if nextShowAt != nil {
|
||
updateSQL = `
|
||
UPDATE tasks
|
||
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
|
||
`
|
||
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, newWishlistID, rewardPolicyValue, taskID}
|
||
} else {
|
||
updateSQL = `
|
||
UPDATE tasks
|
||
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, wishlist_id = $5, reward_policy = $6
|
||
WHERE id = $7
|
||
`
|
||
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, newWishlistID, rewardPolicyValue, taskID}
|
||
}
|
||
} else {
|
||
updateSQL = `
|
||
UPDATE tasks
|
||
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
|
||
`
|
||
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, newWishlistID, rewardPolicyValue, taskID}
|
||
}
|
||
|
||
_, err = tx.Exec(updateSQL, updateArgs...)
|
||
|
||
if err != nil {
|
||
log.Printf("Error updating task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Удаляем старые награды основной задачи
|
||
_, err = tx.Exec("DELETE FROM reward_configs WHERE task_id = $1", taskID)
|
||
if err != nil {
|
||
log.Printf("Error deleting old rewards: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error deleting old rewards: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Вставляем новые награды
|
||
for _, rewardReq := range req.Rewards {
|
||
projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID)
|
||
if err != nil {
|
||
log.Printf("Error finding project %s: %v", rewardReq.ProjectName, err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
_, err = tx.Exec(`
|
||
INSERT INTO reward_configs (position, task_id, project_id, value, use_progression)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
`, rewardReq.Position, taskID, projectID, rewardReq.Value, rewardReq.UseProgression)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating reward: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating reward: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Получаем список текущих подзадач
|
||
currentSubtaskIDs := make(map[int]bool)
|
||
rows, err := tx.Query("SELECT id FROM tasks WHERE parent_task_id = $1 AND deleted = FALSE", taskID)
|
||
if err == nil {
|
||
for rows.Next() {
|
||
var id int
|
||
if err := rows.Scan(&id); err == nil {
|
||
currentSubtaskIDs[id] = true
|
||
}
|
||
}
|
||
rows.Close()
|
||
}
|
||
|
||
// Обрабатываем подзадачи из запроса
|
||
subtaskIDsInRequest := make(map[int]bool)
|
||
for index, subtaskReq := range req.Subtasks {
|
||
if subtaskReq.ID != nil {
|
||
subtaskIDsInRequest[*subtaskReq.ID] = true
|
||
|
||
// Обновляем существующую подзадачу
|
||
var subtaskName sql.NullString
|
||
var subtaskRewardMessage sql.NullString
|
||
var subtaskProgressionBase sql.NullFloat64
|
||
var subtaskPosition sql.NullInt64
|
||
|
||
if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" {
|
||
subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true}
|
||
}
|
||
if subtaskReq.RewardMessage != nil {
|
||
subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true}
|
||
}
|
||
if req.ProgressionBase != nil {
|
||
subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true}
|
||
}
|
||
// Используем position из запроса, если указан, иначе используем индекс в массиве
|
||
if subtaskReq.Position != nil {
|
||
subtaskPosition = sql.NullInt64{Int64: int64(*subtaskReq.Position), Valid: true}
|
||
} else {
|
||
subtaskPosition = sql.NullInt64{Int64: int64(index), Valid: true}
|
||
}
|
||
|
||
_, err = tx.Exec(`
|
||
UPDATE tasks
|
||
SET name = $1, reward_message = $2, progression_base = $3, position = $4
|
||
WHERE id = $5 AND parent_task_id = $6
|
||
`, subtaskName, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition, *subtaskReq.ID, taskID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error updating subtask: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating subtask: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Удаляем старые награды подзадачи
|
||
_, err = tx.Exec("DELETE FROM reward_configs WHERE task_id = $1", *subtaskReq.ID)
|
||
if err != nil {
|
||
log.Printf("Error deleting old subtask rewards: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error deleting old subtask rewards: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Вставляем новые награды подзадачи
|
||
for _, rewardReq := range subtaskReq.Rewards {
|
||
if strings.TrimSpace(rewardReq.ProjectName) == "" {
|
||
sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID)
|
||
if err != nil {
|
||
log.Printf("Error finding project %s for subtask: %v", rewardReq.ProjectName, err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
_, err = tx.Exec(`
|
||
INSERT INTO reward_configs (position, task_id, project_id, value, use_progression)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
`, rewardReq.Position, *subtaskReq.ID, projectID, rewardReq.Value, rewardReq.UseProgression)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating subtask reward: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
} else {
|
||
// Создаем новую подзадачу
|
||
var subtaskName sql.NullString
|
||
var subtaskRewardMessage sql.NullString
|
||
var subtaskProgressionBase sql.NullFloat64
|
||
var subtaskPosition sql.NullInt64
|
||
|
||
if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" {
|
||
subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true}
|
||
}
|
||
if subtaskReq.RewardMessage != nil {
|
||
subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true}
|
||
}
|
||
if req.ProgressionBase != nil {
|
||
subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true}
|
||
}
|
||
// Используем position из запроса, если указан, иначе используем индекс в массиве
|
||
if subtaskReq.Position != nil {
|
||
subtaskPosition = sql.NullInt64{Int64: int64(*subtaskReq.Position), Valid: true}
|
||
} else {
|
||
subtaskPosition = sql.NullInt64{Int64: int64(index), Valid: true}
|
||
}
|
||
|
||
var subtaskID int
|
||
err = tx.QueryRow(`
|
||
INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted, position)
|
||
VALUES ($1, $2, $3, $4, $5, 0, FALSE, $6)
|
||
RETURNING id
|
||
`, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase, subtaskPosition).Scan(&subtaskID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating subtask: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Создаем награды для новой подзадачи
|
||
for _, rewardReq := range subtaskReq.Rewards {
|
||
if strings.TrimSpace(rewardReq.ProjectName) == "" {
|
||
sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID)
|
||
if err != nil {
|
||
log.Printf("Error finding project %s for new subtask: %v", rewardReq.ProjectName, err)
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
_, err = tx.Exec(`
|
||
INSERT INTO reward_configs (position, task_id, project_id, value, use_progression)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
`, rewardReq.Position, subtaskID, projectID, rewardReq.Value, rewardReq.UseProgression)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating subtask reward: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Помечаем подзадачи, которые были в БД, но не пришли в запросе, как deleted
|
||
for subtaskID := range currentSubtaskIDs {
|
||
if !subtaskIDsInRequest[subtaskID] {
|
||
_, err = tx.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1", subtaskID)
|
||
if err != nil {
|
||
log.Printf("Error marking subtask as deleted: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error marking subtask as deleted: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// Получаем текущий config_id задачи
|
||
var currentConfigID sql.NullInt64
|
||
err = tx.QueryRow("SELECT config_id FROM tasks WHERE id = $1", taskID).Scan(¤tConfigID)
|
||
if err != nil {
|
||
log.Printf("Error getting current config_id: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting task config: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Обработка конфигурации теста
|
||
if req.IsTest {
|
||
// Валидация: для теста должны быть указаны words_count и хотя бы один словарь
|
||
if req.WordsCount == nil || *req.WordsCount < 1 {
|
||
sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest)
|
||
return
|
||
}
|
||
if len(req.DictionaryIDs) == 0 {
|
||
sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if currentConfigID.Valid {
|
||
// Обновляем существующую конфигурацию
|
||
if req.MaxCards != nil {
|
||
_, err = tx.Exec(`
|
||
UPDATE configs SET words_count = $1, max_cards = $2 WHERE id = $3
|
||
`, *req.WordsCount, *req.MaxCards, currentConfigID.Int64)
|
||
} else {
|
||
_, err = tx.Exec(`
|
||
UPDATE configs SET words_count = $1, max_cards = NULL WHERE id = $2
|
||
`, *req.WordsCount, currentConfigID.Int64)
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error updating config: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating config: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Обновляем связи со словарями
|
||
_, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64)
|
||
if err != nil {
|
||
log.Printf("Error deleting config dictionaries: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating config dictionaries: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
for _, dictID := range req.DictionaryIDs {
|
||
_, err = tx.Exec(`
|
||
INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2)
|
||
`, currentConfigID.Int64, dictID)
|
||
if err != nil {
|
||
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
} else {
|
||
// Создаем новую конфигурацию для существующей задачи
|
||
var newConfigID int
|
||
if req.MaxCards != nil {
|
||
err = tx.QueryRow(`
|
||
INSERT INTO configs (user_id, words_count, max_cards) VALUES ($1, $2, $3) RETURNING id
|
||
`, userID, *req.WordsCount, *req.MaxCards).Scan(&newConfigID)
|
||
} else {
|
||
err = tx.QueryRow(`
|
||
INSERT INTO configs (user_id, words_count) VALUES ($1, $2) RETURNING id
|
||
`, userID, *req.WordsCount).Scan(&newConfigID)
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error creating config: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
for _, dictID := range req.DictionaryIDs {
|
||
_, err = tx.Exec(`
|
||
INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2)
|
||
`, newConfigID, dictID)
|
||
if err != nil {
|
||
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
_, err = tx.Exec("UPDATE tasks SET config_id = $1 WHERE id = $2", newConfigID, taskID)
|
||
if err != nil {
|
||
log.Printf("Error linking config to task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
} else if currentConfigID.Valid {
|
||
// Задача перестала быть тестом - удаляем конфигурацию
|
||
_, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64)
|
||
if err != nil {
|
||
log.Printf("Error deleting config dictionaries: %v", err)
|
||
}
|
||
_, err = tx.Exec("DELETE FROM configs WHERE id = $1", currentConfigID.Int64)
|
||
if err != nil {
|
||
log.Printf("Error deleting config: %v", err)
|
||
}
|
||
_, err = tx.Exec("UPDATE tasks SET config_id = NULL WHERE id = $1", taskID)
|
||
if err != nil {
|
||
log.Printf("Error unlinking config from task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error unlinking config from task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Коммитим транзакцию
|
||
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
|
||
var updatedRepetitionDate sql.NullString
|
||
err = a.DB.QueryRow(`
|
||
SELECT id, name, completed, last_completed_at, reward_message, progression_base, repetition_period::text, repetition_date
|
||
FROM tasks
|
||
WHERE id = $1
|
||
`, taskID).Scan(
|
||
&updatedTask.ID, &updatedTask.Name, &updatedTask.Completed,
|
||
&lastCompletedAt, &rewardMessage, &progressionBase, &updatedRepetitionPeriod, &updatedRepetitionDate,
|
||
)
|
||
|
||
if err != nil {
|
||
log.Printf("Error fetching updated task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error fetching updated task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if rewardMessage.Valid {
|
||
updatedTask.RewardMessage = &rewardMessage.String
|
||
}
|
||
if progressionBase.Valid {
|
||
updatedTask.ProgressionBase = &progressionBase.Float64
|
||
}
|
||
if lastCompletedAt.Valid {
|
||
updatedTask.LastCompletedAt = &lastCompletedAt.String
|
||
}
|
||
if updatedRepetitionPeriod.Valid {
|
||
updatedTask.RepetitionPeriod = &updatedRepetitionPeriod.String
|
||
}
|
||
if updatedRepetitionDate.Valid {
|
||
updatedTask.RepetitionDate = &updatedRepetitionDate.String
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(updatedTask)
|
||
}
|
||
|
||
// saveTaskDraftHandler сохраняет или обновляет драфт задачи
|
||
func (a *App) saveTaskDraftHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
taskID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем владельца задачи
|
||
var ownerID int
|
||
err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows || ownerID != userID {
|
||
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking task ownership: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
var req SaveDraftRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding save draft request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Начинаем транзакцию
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Проверяем, существует ли драфт
|
||
var draftID int
|
||
err = tx.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID)
|
||
|
||
var progressionValue sql.NullFloat64
|
||
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)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Draft saved successfully",
|
||
})
|
||
}
|
||
|
||
// deleteTaskHandler удаляет задачу (помечает как deleted)
|
||
func (a *App) deleteTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
taskID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем владельца
|
||
var ownerID int
|
||
err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1", taskID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows || ownerID != userID {
|
||
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking task ownership: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Помечаем задачу как удаленную
|
||
_, err = a.DB.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1 AND user_id = $2", taskID, userID)
|
||
if err != nil {
|
||
log.Printf("Error deleting task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error deleting task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Task deleted successfully",
|
||
})
|
||
}
|
||
|
||
// executeTask выполняет задачу (вынесенная логика)
|
||
// Удаляет драфт перед выполнением и выполняет всю логику выполнения задачи
|
||
func (a *App) executeTask(taskID int, userID int, req CompleteTaskRequest) error {
|
||
// Удаляем драфт перед выполнением (если есть)
|
||
_, err := a.DB.Exec(`DELETE FROM task_drafts WHERE task_id = $1`, taskID)
|
||
if err != nil {
|
||
log.Printf("Error deleting draft for task %d: %v", taskID, err)
|
||
// Не возвращаем ошибку, продолжаем выполнение
|
||
}
|
||
|
||
// Получаем задачу и проверяем владельца
|
||
var task Task
|
||
var rewardMessage sql.NullString
|
||
var progressionBase sql.NullFloat64
|
||
var repetitionPeriod sql.NullString
|
||
var repetitionDate sql.NullString
|
||
var ownerID int
|
||
var wishlistID sql.NullInt64
|
||
|
||
err = a.DB.QueryRow(`
|
||
SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id, wishlist_id
|
||
FROM tasks
|
||
WHERE id = $1 AND deleted = FALSE
|
||
`, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID, &wishlistID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
return fmt.Errorf("task not found")
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error querying task: %v", err)
|
||
return fmt.Errorf("error querying task: %v", err)
|
||
}
|
||
|
||
if ownerID != userID {
|
||
return fmt.Errorf("task not found")
|
||
}
|
||
|
||
// Проверяем, что желание разблокировано (если задача связана с желанием)
|
||
if wishlistID.Valid {
|
||
unlocked, err := a.checkWishlistUnlock(int(wishlistID.Int64), userID)
|
||
if err != nil {
|
||
log.Printf("Error checking wishlist unlock status: %v", err)
|
||
return fmt.Errorf("error checking wishlist unlock status: %v", err)
|
||
}
|
||
if !unlocked {
|
||
return fmt.Errorf("cannot complete task: wishlist item is not unlocked")
|
||
}
|
||
}
|
||
|
||
// Валидация: если progression_base != null, то value обязателен
|
||
if progressionBase.Valid && req.Value == nil {
|
||
return fmt.Errorf("value is required when progression_base is set")
|
||
}
|
||
|
||
if rewardMessage.Valid {
|
||
task.RewardMessage = &rewardMessage.String
|
||
}
|
||
if progressionBase.Valid {
|
||
task.ProgressionBase = &progressionBase.Float64
|
||
}
|
||
|
||
// Получаем награды основной задачи
|
||
rewardRows, err := a.DB.Query(`
|
||
SELECT rc.position, p.name AS project_name, rc.value, rc.use_progression
|
||
FROM reward_configs rc
|
||
JOIN projects p ON rc.project_id = p.id
|
||
WHERE rc.task_id = $1
|
||
ORDER BY rc.position
|
||
`, taskID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error querying rewards: %v", err)
|
||
return fmt.Errorf("error querying rewards: %v", err)
|
||
}
|
||
defer rewardRows.Close()
|
||
|
||
rewards := make([]Reward, 0)
|
||
for rewardRows.Next() {
|
||
var reward Reward
|
||
err := rewardRows.Scan(&reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression)
|
||
if err != nil {
|
||
log.Printf("Error scanning reward: %v", err)
|
||
continue
|
||
}
|
||
rewards = append(rewards, reward)
|
||
}
|
||
|
||
// Вычисляем score для каждой награды и формируем строки для подстановки
|
||
rewardStrings := make(map[int]string)
|
||
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
|
||
}
|
||
|
||
// Функция для замены плейсхолдеров в сообщении награды
|
||
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}, и т.д.
|
||
for i := 0; i < 100; i++ { // Максимум 100 плейсхолдеров
|
||
placeholder := fmt.Sprintf("${%d}", i)
|
||
if rewardStr, ok := rewardStrings[i]; ok {
|
||
result = strings.ReplaceAll(result, placeholder, rewardStr)
|
||
}
|
||
}
|
||
// Затем заменяем $0, $1, и т.д. (экранированные уже защищены маркерами)
|
||
// Ищем $N, где после N не идет еще одна цифра (чтобы не заменить $10 при поиске $1)
|
||
// Go regexp не поддерживает lookahead, поэтому заменяем с конца (от больших чисел к меньшим)
|
||
for i := 99; i >= 0; i-- {
|
||
if rewardStr, ok := rewardStrings[i]; ok {
|
||
searchStr := fmt.Sprintf("$%d", i)
|
||
// Ищем все вхождения с конца строки
|
||
for {
|
||
idx := strings.LastIndex(result, searchStr)
|
||
if idx == -1 {
|
||
break
|
||
}
|
||
// Проверяем, что после $N не идет еще одна цифра
|
||
afterIdx := idx + len(searchStr)
|
||
if afterIdx >= len(result) || result[afterIdx] < '0' || result[afterIdx] > '9' {
|
||
// Можно заменить
|
||
result = result[:idx] + rewardStr + result[afterIdx:]
|
||
} else {
|
||
// После $N идет еще цифра (например, $10), пропускаем
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Восстанавливаем экранированные доллары из временных маркеров
|
||
for marker, escaped := range escapedMarkers {
|
||
result = strings.ReplaceAll(result, marker, escaped)
|
||
}
|
||
return result
|
||
}
|
||
|
||
// Подставляем в reward_message основной задачи
|
||
var mainTaskMessage string
|
||
if task.RewardMessage != nil && *task.RewardMessage != "" {
|
||
mainTaskMessage = replaceRewardPlaceholders(*task.RewardMessage, rewardStrings)
|
||
} 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 подзадачи
|
||
subtaskMessage := replaceRewardPlaceholders(subtaskRewardMessage.String, subtaskRewardStrings)
|
||
|
||
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 для основной задачи
|
||
// Если repetition_date установлен, вычисляем next_show_at
|
||
// Если repetition_period не установлен и repetition_date не установлен, помечаем задачу как удаленную
|
||
// Если repetition_period = "0 day" (или любое значение с 0), не обновляем last_completed_at
|
||
|
||
// Проверяем наличие repetition_date (используем COALESCE, поэтому пустая строка означает отсутствие)
|
||
hasRepetitionDate := repetitionDate.Valid && strings.TrimSpace(repetitionDate.String) != ""
|
||
|
||
if hasRepetitionDate {
|
||
// Есть repetition_date - вычисляем следующую дату показа
|
||
|
||
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
||
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||
loc, err := time.LoadLocation(timezoneStr)
|
||
if err != nil {
|
||
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
|
||
loc = time.UTC
|
||
}
|
||
|
||
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc))
|
||
if nextShowAt != nil {
|
||
_, err = a.DB.Exec(`
|
||
UPDATE tasks
|
||
SET completed = completed + 1, last_completed_at = NOW(), next_show_at = $2
|
||
WHERE id = $1
|
||
`, taskID, nextShowAt)
|
||
} else {
|
||
// Если не удалось вычислить дату, обновляем как обычно
|
||
_, err = a.DB.Exec(`
|
||
UPDATE tasks
|
||
SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL
|
||
WHERE id = $1
|
||
`, taskID)
|
||
}
|
||
} else if repetitionPeriod.Valid {
|
||
// Проверяем, является ли период нулевым (начинается с "0 ")
|
||
periodStr := strings.TrimSpace(repetitionPeriod.String)
|
||
isZeroPeriod := strings.HasPrefix(periodStr, "0 ") || periodStr == "0"
|
||
|
||
if isZeroPeriod {
|
||
// Период = 0: обновляем только счетчик, но не last_completed_at
|
||
// Задача никогда не будет переноситься в выполненные
|
||
_, err = a.DB.Exec(`
|
||
UPDATE tasks
|
||
SET completed = completed + 1, next_show_at = NULL
|
||
WHERE id = $1
|
||
`, taskID)
|
||
} else {
|
||
// Обычный период: обновляем счетчик и last_completed_at, вычисляем next_show_at
|
||
// next_show_at = last_completed_at + repetition_period
|
||
|
||
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
|
||
timezoneStr := getEnv("TIMEZONE", "UTC")
|
||
loc, err := time.LoadLocation(timezoneStr)
|
||
if err != nil {
|
||
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
|
||
loc = time.UTC
|
||
}
|
||
|
||
now := time.Now().In(loc)
|
||
log.Printf("Calculating next_show_at for task %d: repetition_period='%s', fromDate=%v (timezone: %s)", taskID, repetitionPeriod.String, now, timezoneStr)
|
||
nextShowAt := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, now)
|
||
if nextShowAt != nil {
|
||
log.Printf("Calculated next_show_at for task %d: %v", taskID, *nextShowAt)
|
||
_, err = a.DB.Exec(`
|
||
UPDATE tasks
|
||
SET completed = completed + 1, last_completed_at = NOW(), next_show_at = $2
|
||
WHERE id = $1
|
||
`, taskID, nextShowAt)
|
||
} else {
|
||
log.Printf("Failed to calculate next_show_at for task %d: repetition_period='%s' returned nil", taskID, repetitionPeriod.String)
|
||
// Если не удалось вычислить дату, обновляем как обычно
|
||
_, err = a.DB.Exec(`
|
||
UPDATE tasks
|
||
SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL
|
||
WHERE id = $1
|
||
`, taskID)
|
||
}
|
||
}
|
||
} else {
|
||
_, err = a.DB.Exec(`
|
||
UPDATE tasks
|
||
SET completed = completed + 1, last_completed_at = NOW(), next_show_at = NULL, deleted = TRUE
|
||
WHERE id = $1
|
||
`, taskID)
|
||
}
|
||
|
||
if err != nil {
|
||
log.Printf("Error updating task completion: %v", err)
|
||
return fmt.Errorf("error updating task completion: %v", err)
|
||
}
|
||
|
||
// Обновляем выбранные подзадачи
|
||
if len(req.ChildrenTaskIDs) > 0 {
|
||
placeholders := make([]string, len(req.ChildrenTaskIDs))
|
||
args := make([]interface{}, len(req.ChildrenTaskIDs))
|
||
for i, id := range req.ChildrenTaskIDs {
|
||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||
args[i] = id
|
||
}
|
||
|
||
query := fmt.Sprintf(`
|
||
UPDATE tasks
|
||
SET completed = completed + 1, last_completed_at = NOW()
|
||
WHERE id IN (%s) AND deleted = FALSE
|
||
`, strings.Join(placeholders, ","))
|
||
|
||
_, err = a.DB.Exec(query, args...)
|
||
if err != nil {
|
||
log.Printf("Error updating subtasks completion: %v", err)
|
||
// Не возвращаем ошибку, основная задача уже обновлена
|
||
}
|
||
}
|
||
|
||
// Если задача связана с желанием, завершаем желание и обрабатываем политику награждения
|
||
if wishlistID.Valid {
|
||
// Завершаем желание
|
||
_, completeErr := a.DB.Exec(`
|
||
UPDATE wishlist_items
|
||
SET completed = TRUE, updated_at = NOW()
|
||
WHERE id = $1 AND completed = FALSE
|
||
`, wishlistID.Int64)
|
||
if completeErr != nil {
|
||
log.Printf("Error completing wishlist item %d: %v", wishlistID.Int64, completeErr)
|
||
// Не возвращаем ошибку, задача уже выполнена
|
||
} else {
|
||
log.Printf("Wishlist item %d completed automatically after task %d completion", wishlistID.Int64, taskID)
|
||
// Обрабатываем политику награждения для всех задач, связанных с этим желанием
|
||
// Исключаем задачу, которая была закрыта (taskID), чтобы не обрабатывать её повторно
|
||
a.processWishlistRewardPolicy(int(wishlistID.Int64), taskID)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// completeTaskAtEndOfDayHandler устанавливает автовыполнение задачи в конце дня
|
||
func (a *App) completeTaskAtEndOfDayHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
taskID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем владельца задачи
|
||
var ownerID int
|
||
err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows || ownerID != userID {
|
||
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking task ownership: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
var req SaveDraftRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding save draft request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Устанавливаем auto_complete = true
|
||
req.AutoComplete = true
|
||
|
||
// Используем ту же логику что и saveTaskDraftHandler
|
||
// Начинаем транзакцию
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Проверяем, существует ли драфт
|
||
var draftID int
|
||
err = tx.QueryRow("SELECT id FROM task_drafts WHERE task_id = $1", taskID).Scan(&draftID)
|
||
|
||
var progressionValue sql.NullFloat64
|
||
if req.ProgressionValue != nil {
|
||
progressionValue = sql.NullFloat64{Float64: *req.ProgressionValue, Valid: true}
|
||
}
|
||
|
||
if err == sql.ErrNoRows {
|
||
// Создаем новый драфт
|
||
err = tx.QueryRow(`
|
||
INSERT INTO task_drafts (task_id, user_id, progression_value, auto_complete, created_at, updated_at)
|
||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||
RETURNING id
|
||
`, taskID, userID, progressionValue, req.AutoComplete).Scan(&draftID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating draft: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating draft: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
} else if err != nil {
|
||
log.Printf("Error checking draft existence: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking draft existence: %v", err), http.StatusInternalServerError)
|
||
return
|
||
} else {
|
||
// Обновляем существующий драфт с auto_complete = true
|
||
_, err = tx.Exec(`
|
||
UPDATE task_drafts
|
||
SET progression_value = $1, auto_complete = $2, updated_at = NOW()
|
||
WHERE id = $3
|
||
`, progressionValue, req.AutoComplete, draftID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error updating draft: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating draft: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Удаляем все старые записи подзадач
|
||
_, 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)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Task will be completed at end of day",
|
||
})
|
||
}
|
||
|
||
// completeTaskHandler выполняет задачу
|
||
func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
taskID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
var req CompleteTaskRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding complete task request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Используем executeTask для выполнения задачи
|
||
err = a.executeTask(taskID, userID, req)
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "not found") {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusNotFound)
|
||
} else if strings.Contains(err.Error(), "unlocked") {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
} else if strings.Contains(err.Error(), "required") {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
} else {
|
||
log.Printf("Error executing task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error executing task: %v", err), http.StatusInternalServerError)
|
||
}
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Task completed successfully",
|
||
})
|
||
}
|
||
|
||
// completeAndDeleteTaskHandler выполняет задачу и затем удаляет её
|
||
func (a *App) completeAndDeleteTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
taskID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Сначала выполняем задачу используя executeTask
|
||
var req CompleteTaskRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding complete task request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Используем executeTask для выполнения задачи
|
||
err = a.executeTask(taskID, userID, req)
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "not found") {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusNotFound)
|
||
} else if strings.Contains(err.Error(), "unlocked") {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
} else if strings.Contains(err.Error(), "required") {
|
||
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
||
} else {
|
||
log.Printf("Error executing task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error executing task: %v", err), http.StatusInternalServerError)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Помечаем задачу как удаленную
|
||
_, err = a.DB.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1 AND user_id = $2", taskID, userID)
|
||
if err != nil {
|
||
log.Printf("Error deleting task: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error deleting task: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Task completed and deleted successfully",
|
||
})
|
||
}
|
||
|
||
// postponeTaskHandler переносит задачу на указанную дату
|
||
func (a *App) postponeTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
taskID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid task ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
var req PostponeTaskRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding postpone task request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем владельца
|
||
var ownerID int
|
||
err = a.DB.QueryRow("SELECT user_id FROM tasks WHERE id = $1 AND deleted = FALSE", taskID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows || ownerID != userID {
|
||
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking task ownership: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking task ownership: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Если NextShowAt == nil, устанавливаем next_show_at в NULL
|
||
// Иначе парсим дату и устанавливаем значение
|
||
var nextShowAtValue interface{}
|
||
if req.NextShowAt == nil || *req.NextShowAt == "" {
|
||
nextShowAtValue = nil
|
||
} else {
|
||
nextShowAt, err := time.Parse(time.RFC3339, *req.NextShowAt)
|
||
if err != nil {
|
||
log.Printf("Error parsing next_show_at: %v", err)
|
||
sendErrorWithCORS(w, "Invalid date format. Use RFC3339 format", http.StatusBadRequest)
|
||
return
|
||
}
|
||
nextShowAtValue = nextShowAt
|
||
}
|
||
|
||
// Обновляем next_show_at
|
||
_, err = a.DB.Exec(`
|
||
UPDATE tasks
|
||
SET next_show_at = $1
|
||
WHERE id = $2 AND user_id = $3
|
||
`, nextShowAtValue, taskID, userID)
|
||
if err != nil {
|
||
log.Printf("Error updating next_show_at: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating next_show_at: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Task postponed successfully",
|
||
})
|
||
}
|
||
|
||
// todoistDisconnectHandler отключает интеграцию Todoist
|
||
func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
_, err := a.DB.Exec(`
|
||
DELETE FROM todoist_integrations WHERE user_id = $1
|
||
`, userID)
|
||
|
||
if err != nil {
|
||
log.Printf("Todoist disconnect: DB error: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Failed to disconnect: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
log.Printf("Todoist disconnected for user_id=%d", userID)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Todoist disconnected",
|
||
})
|
||
}
|
||
|
||
// ============================================
|
||
// Wishlist handlers
|
||
// ============================================
|
||
|
||
// calculateProjectPointsFromDate считает баллы проекта с указанной даты до текущего момента
|
||
// Считает напрямую из таблицы nodes, используя денормализованное поле created_date
|
||
func (a *App) calculateProjectPointsFromDate(
|
||
projectID int,
|
||
startDate sql.NullTime,
|
||
userID int,
|
||
) (float64, error) {
|
||
var totalScore float64
|
||
var err error
|
||
|
||
if !startDate.Valid {
|
||
// За всё время - считаем все nodes этого пользователя для указанного проекта
|
||
err = a.DB.QueryRow(`
|
||
SELECT COALESCE(SUM(n.score), 0)
|
||
FROM nodes n
|
||
JOIN projects p ON n.project_id = p.id
|
||
WHERE n.project_id = $1 AND n.user_id = $2 AND p.user_id = $2
|
||
`, projectID, userID).Scan(&totalScore)
|
||
} else {
|
||
// С указанной даты до текущего момента
|
||
// Считаем все nodes этого пользователя, где дата created_date >= startDate
|
||
// Используем DATE() для сравнения только по дате (без времени)
|
||
// Теперь используем nodes.created_date напрямую (без JOIN с entries)
|
||
err = a.DB.QueryRow(`
|
||
SELECT COALESCE(SUM(n.score), 0)
|
||
FROM nodes n
|
||
JOIN projects p ON n.project_id = p.id
|
||
WHERE n.project_id = $1
|
||
AND n.user_id = $2
|
||
AND p.user_id = $2
|
||
AND DATE(n.created_date) >= DATE($3)
|
||
`, projectID, userID, startDate.Time).Scan(&totalScore)
|
||
}
|
||
|
||
if err != nil {
|
||
log.Printf("Error calculating project points from date: %v", err)
|
||
return 0, err
|
||
}
|
||
|
||
return totalScore, nil
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// checkWishlistUnlock проверяет ВСЕ условия для желания
|
||
// Все условия должны выполняться (AND логика)
|
||
func (a *App) checkWishlistUnlock(itemID int, userID int) (bool, error) {
|
||
// Получаем все условия разблокировки
|
||
rows, err := a.DB.Query(`
|
||
SELECT
|
||
wc.id,
|
||
wc.display_order,
|
||
wc.task_condition_id,
|
||
wc.score_condition_id,
|
||
wc.user_id AS condition_user_id,
|
||
tc.task_id,
|
||
sc.project_id,
|
||
sc.required_points,
|
||
sc.start_date
|
||
FROM wishlist_conditions wc
|
||
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||
WHERE wc.wishlist_item_id = $1
|
||
ORDER BY wc.display_order, wc.id
|
||
`, itemID)
|
||
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
var hasConditions bool
|
||
var allConditionsMet = true
|
||
|
||
for rows.Next() {
|
||
hasConditions = true
|
||
|
||
var wcID, displayOrder int
|
||
var taskConditionID, scoreConditionID sql.NullInt64
|
||
var conditionUserID sql.NullInt64
|
||
var taskID sql.NullInt64
|
||
var projectID sql.NullInt64
|
||
var requiredPoints sql.NullFloat64
|
||
var startDate sql.NullTime
|
||
|
||
err := rows.Scan(
|
||
&wcID, &displayOrder,
|
||
&taskConditionID, &scoreConditionID, &conditionUserID,
|
||
&taskID, &projectID, &requiredPoints, &startDate,
|
||
)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
// Используем user_id из условия, если он есть, иначе используем текущего пользователя
|
||
conditionOwnerID := userID
|
||
if conditionUserID.Valid {
|
||
conditionOwnerID = int(conditionUserID.Int64)
|
||
}
|
||
|
||
var conditionMet bool
|
||
|
||
if taskConditionID.Valid {
|
||
// Проверяем условие по задаче
|
||
if !taskID.Valid {
|
||
return false, fmt.Errorf("task_id is missing for task_condition_id=%d", taskConditionID.Int64)
|
||
}
|
||
|
||
var completed int
|
||
err := a.DB.QueryRow(`
|
||
SELECT completed
|
||
FROM tasks
|
||
WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
||
`, taskID.Int64, conditionOwnerID).Scan(&completed)
|
||
|
||
if err == sql.ErrNoRows {
|
||
// Задача удалена или не существует - не блокируем желание
|
||
conditionMet = true
|
||
} else if err != nil {
|
||
return false, err
|
||
} else {
|
||
conditionMet = 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)
|
||
}
|
||
|
||
totalScore, err := a.calculateProjectPointsFromDate(
|
||
int(projectID.Int64),
|
||
startDate,
|
||
conditionOwnerID,
|
||
)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
|
||
conditionMet = totalScore >= requiredPoints.Float64
|
||
} else {
|
||
return false, fmt.Errorf("invalid condition: neither task nor score condition")
|
||
}
|
||
|
||
if !conditionMet {
|
||
allConditionsMet = false
|
||
}
|
||
}
|
||
|
||
// Если нет условий - желание разблокировано по умолчанию
|
||
if !hasConditions {
|
||
return true, nil
|
||
}
|
||
|
||
return allConditionsMet, nil
|
||
}
|
||
|
||
// isConditionLocked определяет, заблокировано ли условие
|
||
func isConditionLocked(cond UnlockConditionDisplay) bool {
|
||
if cond.Type == "task_completion" {
|
||
return cond.TaskCompleted == nil || !*cond.TaskCompleted
|
||
} else if cond.Type == "project_points" {
|
||
return cond.CurrentPoints == nil || cond.RequiredPoints == nil || *cond.CurrentPoints < *cond.RequiredPoints
|
||
}
|
||
return false
|
||
}
|
||
|
||
// getConditionUnlockWeeks возвращает количество недель для разблокировки условия
|
||
// Используется для сортировки заблокированных условий по баллам
|
||
func (a *App) getConditionUnlockWeeks(cond UnlockConditionDisplay, userID int) float64 {
|
||
if cond.Type != "project_points" {
|
||
return 0
|
||
}
|
||
if cond.ProjectID == nil || cond.RequiredPoints == nil {
|
||
return 99999.0
|
||
}
|
||
|
||
var startDate sql.NullTime
|
||
if cond.StartDate != nil {
|
||
date, err := time.Parse("2006-01-02", *cond.StartDate)
|
||
if err == nil {
|
||
startDate = sql.NullTime{Time: date, Valid: true}
|
||
}
|
||
}
|
||
|
||
conditionOwnerID := userID
|
||
if cond.UserID != nil {
|
||
conditionOwnerID = *cond.UserID
|
||
}
|
||
|
||
return a.calculateProjectUnlockWeeks(*cond.ProjectID, *cond.RequiredPoints, startDate, conditionOwnerID)
|
||
}
|
||
|
||
// sortUnlockConditions сортирует условия в следующем порядке:
|
||
// 1. Заблокированные задачи (по алфавиту)
|
||
// 2. Заблокированные баллы (по сроку от меньшего к большему)
|
||
// 3. Разблокированные задачи (по алфавиту)
|
||
// 4. Разблокированные баллы (по алфавиту)
|
||
func (a *App) sortUnlockConditions(conditions []UnlockConditionDisplay, userID int) {
|
||
sort.Slice(conditions, func(i, j int) bool {
|
||
condI := conditions[i]
|
||
condJ := conditions[j]
|
||
|
||
lockedI := isConditionLocked(condI)
|
||
lockedJ := isConditionLocked(condJ)
|
||
|
||
// 1. Заблокированные идут перед разблокированными
|
||
if lockedI != lockedJ {
|
||
return lockedI // lockedI == true идет первым
|
||
}
|
||
|
||
// Если оба заблокированы или оба разблокированы, сортируем по типу
|
||
if lockedI {
|
||
// Заблокированные: задачи идут перед баллами
|
||
if condI.Type == "task_completion" && condJ.Type == "project_points" {
|
||
return true
|
||
}
|
||
if condI.Type == "project_points" && condJ.Type == "task_completion" {
|
||
return false
|
||
}
|
||
|
||
// Если оба одного типа
|
||
if condI.Type == "task_completion" {
|
||
// Заблокированные задачи: по алфавиту
|
||
taskNameI := ""
|
||
taskNameJ := ""
|
||
if condI.TaskName != nil {
|
||
taskNameI = *condI.TaskName
|
||
}
|
||
if condJ.TaskName != nil {
|
||
taskNameJ = *condJ.TaskName
|
||
}
|
||
if taskNameI != taskNameJ {
|
||
return taskNameI < taskNameJ
|
||
}
|
||
return condI.ID < condJ.ID
|
||
} else {
|
||
// Заблокированные баллы: по сроку от меньшего к большему
|
||
weeksI := a.getConditionUnlockWeeks(condI, userID)
|
||
weeksJ := a.getConditionUnlockWeeks(condJ, userID)
|
||
if weeksI != weeksJ {
|
||
return weeksI < weeksJ
|
||
}
|
||
// Если сроки равны, сортируем по алфавиту по названию проекта
|
||
projectNameI := ""
|
||
projectNameJ := ""
|
||
if condI.ProjectName != nil {
|
||
projectNameI = *condI.ProjectName
|
||
}
|
||
if condJ.ProjectName != nil {
|
||
projectNameJ = *condJ.ProjectName
|
||
}
|
||
if projectNameI != projectNameJ {
|
||
return projectNameI < projectNameJ
|
||
}
|
||
return condI.ID < condJ.ID
|
||
}
|
||
} else {
|
||
// Разблокированные: задачи идут перед баллами
|
||
if condI.Type == "task_completion" && condJ.Type == "project_points" {
|
||
return true
|
||
}
|
||
if condI.Type == "project_points" && condJ.Type == "task_completion" {
|
||
return false
|
||
}
|
||
|
||
// Если оба одного типа, сортируем по алфавиту
|
||
if condI.Type == "task_completion" {
|
||
// Разблокированные задачи: по алфавиту
|
||
taskNameI := ""
|
||
taskNameJ := ""
|
||
if condI.TaskName != nil {
|
||
taskNameI = *condI.TaskName
|
||
}
|
||
if condJ.TaskName != nil {
|
||
taskNameJ = *condJ.TaskName
|
||
}
|
||
if taskNameI != taskNameJ {
|
||
return taskNameI < taskNameJ
|
||
}
|
||
return condI.ID < condJ.ID
|
||
} else {
|
||
// Разблокированные баллы: по алфавиту
|
||
projectNameI := ""
|
||
projectNameJ := ""
|
||
if condI.ProjectName != nil {
|
||
projectNameI = *condI.ProjectName
|
||
}
|
||
if condJ.ProjectName != nil {
|
||
projectNameJ = *condJ.ProjectName
|
||
}
|
||
if projectNameI != projectNameJ {
|
||
return projectNameI < projectNameJ
|
||
}
|
||
return condI.ID < condJ.ID
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// getWishlistItemsWithConditions загружает желания с их условиями
|
||
func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) ([]WishlistItem, error) {
|
||
query := `
|
||
SELECT
|
||
wi.id,
|
||
wi.name,
|
||
wi.price,
|
||
wi.image_path,
|
||
wi.link,
|
||
wi.completed,
|
||
wi.project_id AS item_project_id,
|
||
wp.name AS item_project_name,
|
||
wc.id AS condition_id,
|
||
wc.display_order,
|
||
wc.task_condition_id,
|
||
wc.score_condition_id,
|
||
wc.user_id AS condition_user_id,
|
||
tc.task_id,
|
||
t.name AS task_name,
|
||
sc.project_id,
|
||
p.name AS project_name,
|
||
sc.required_points,
|
||
sc.start_date
|
||
FROM wishlist_items wi
|
||
LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE
|
||
LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id
|
||
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||
LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE
|
||
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||
LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE
|
||
WHERE wi.user_id = $1
|
||
AND wi.deleted = FALSE
|
||
AND ($2 = TRUE OR wi.completed = FALSE)
|
||
ORDER BY wi.completed, wi.id, wc.display_order, wc.id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, userID, includeCompleted)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
// Группируем по wishlist_item_id
|
||
itemsMap := make(map[int]*WishlistItem)
|
||
|
||
for rows.Next() {
|
||
var itemID int
|
||
var name string
|
||
var price sql.NullFloat64
|
||
var imagePath, link sql.NullString
|
||
var completed bool
|
||
var itemProjectID sql.NullInt64
|
||
var itemProjectName sql.NullString
|
||
|
||
var conditionID, displayOrder sql.NullInt64
|
||
var taskConditionID, scoreConditionID sql.NullInt64
|
||
var conditionUserID sql.NullInt64
|
||
var taskID sql.NullInt64
|
||
var taskName sql.NullString
|
||
var projectID sql.NullInt64
|
||
var projectName sql.NullString
|
||
var requiredPoints sql.NullFloat64
|
||
var startDate sql.NullTime
|
||
|
||
err := rows.Scan(
|
||
&itemID, &name, &price, &imagePath, &link, &completed,
|
||
&itemProjectID, &itemProjectName,
|
||
&conditionID, &displayOrder,
|
||
&taskConditionID, &scoreConditionID, &conditionUserID,
|
||
&taskID, &taskName,
|
||
&projectID, &projectName, &requiredPoints, &startDate,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Получаем или создаём item
|
||
item, exists := itemsMap[itemID]
|
||
if !exists {
|
||
item = &WishlistItem{
|
||
ID: itemID,
|
||
Name: name,
|
||
Completed: completed,
|
||
UnlockConditions: []UnlockConditionDisplay{},
|
||
}
|
||
if price.Valid {
|
||
p := price.Float64
|
||
item.Price = &p
|
||
}
|
||
if imagePath.Valid {
|
||
url := imagePath.String
|
||
item.ImageURL = &url
|
||
}
|
||
if link.Valid {
|
||
l := link.String
|
||
item.Link = &l
|
||
}
|
||
if itemProjectID.Valid {
|
||
projectIDVal := int(itemProjectID.Int64)
|
||
item.ProjectID = &projectIDVal
|
||
}
|
||
if itemProjectName.Valid {
|
||
projectNameVal := itemProjectName.String
|
||
item.ProjectName = &projectNameVal
|
||
}
|
||
itemsMap[itemID] = item
|
||
}
|
||
|
||
// Добавляем условие, если есть
|
||
if conditionID.Valid {
|
||
// Определяем владельца условия
|
||
conditionOwnerID := userID
|
||
if conditionUserID.Valid {
|
||
conditionOwnerID = int(conditionUserID.Int64)
|
||
}
|
||
|
||
// Если это условие по задаче, проверяем существует ли задача
|
||
if taskConditionID.Valid && taskID.Valid {
|
||
// Проверяем, существует ли задача (не удалена)
|
||
var taskExists bool
|
||
err := a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)`, taskID.Int64, conditionOwnerID).Scan(&taskExists)
|
||
if err != nil || !taskExists {
|
||
// Задача удалена - не добавляем условие в список, но при проверке блокировки оно считается выполненным
|
||
continue
|
||
}
|
||
}
|
||
|
||
condition := UnlockConditionDisplay{
|
||
ID: int(conditionID.Int64),
|
||
DisplayOrder: int(displayOrder.Int64),
|
||
}
|
||
|
||
// Заполняем UserID для условия
|
||
if conditionUserID.Valid {
|
||
conditionOwnerID := int(conditionUserID.Int64)
|
||
condition.UserID = &conditionOwnerID
|
||
} else {
|
||
condition.UserID = &userID
|
||
}
|
||
|
||
if taskConditionID.Valid {
|
||
condition.Type = "task_completion"
|
||
if taskName.Valid {
|
||
condition.TaskName = &taskName.String
|
||
}
|
||
if taskID.Valid {
|
||
taskIDVal := int(taskID.Int64)
|
||
condition.TaskID = &taskIDVal
|
||
}
|
||
} else if scoreConditionID.Valid {
|
||
condition.Type = "project_points"
|
||
if projectName.Valid {
|
||
condition.ProjectName = &projectName.String
|
||
}
|
||
if projectID.Valid {
|
||
projectIDVal := int(projectID.Int64)
|
||
condition.ProjectID = &projectIDVal
|
||
}
|
||
if requiredPoints.Valid {
|
||
condition.RequiredPoints = &requiredPoints.Float64
|
||
}
|
||
if startDate.Valid {
|
||
// Форматируем дату в YYYY-MM-DD
|
||
dateStr := startDate.Time.Format("2006-01-02")
|
||
condition.StartDate = &dateStr
|
||
}
|
||
}
|
||
|
||
item.UnlockConditions = append(item.UnlockConditions, condition)
|
||
}
|
||
}
|
||
|
||
// Конвертируем map в slice и проверяем разблокировку
|
||
items := make([]WishlistItem, 0, len(itemsMap))
|
||
for _, item := range itemsMap {
|
||
unlocked, err := a.checkWishlistUnlock(item.ID, userID)
|
||
if err != nil {
|
||
log.Printf("Error checking unlock for wishlist %d: %v", item.ID, err)
|
||
unlocked = false
|
||
}
|
||
item.Unlocked = unlocked
|
||
|
||
// Сортируем условия в нужном порядке
|
||
a.sortUnlockConditions(item.UnlockConditions, userID)
|
||
|
||
// Определяем первое заблокированное условие и количество остальных, а также рассчитываем прогресс
|
||
if !unlocked && !item.Completed {
|
||
lockedCount := 0
|
||
var firstLocked *UnlockConditionDisplay
|
||
for i := range item.UnlockConditions {
|
||
// Проверяем каждое условие отдельно
|
||
condition := &item.UnlockConditions[i]
|
||
var conditionMet bool
|
||
var err error
|
||
|
||
if condition.Type == "task_completion" {
|
||
// Находим task_id и user_id для этого условия
|
||
var taskID int
|
||
var conditionOwnerID int
|
||
err = a.DB.QueryRow(`
|
||
SELECT tc.task_id, COALESCE(wc.user_id, $2)
|
||
FROM wishlist_conditions wc
|
||
JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||
WHERE wc.id = $1
|
||
`, condition.ID, userID).Scan(&taskID, &conditionOwnerID)
|
||
if err == nil {
|
||
var completed int
|
||
err = a.DB.QueryRow(`
|
||
SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
||
`, taskID, conditionOwnerID).Scan(&completed)
|
||
if err == sql.ErrNoRows {
|
||
// Задача удалена или не существует - не блокируем желание
|
||
conditionMet = true
|
||
completedBool := true
|
||
condition.TaskCompleted = &completedBool
|
||
} else if err == nil {
|
||
conditionMet = completed > 0
|
||
completedBool := conditionMet
|
||
condition.TaskCompleted = &completedBool
|
||
}
|
||
}
|
||
} else if condition.Type == "project_points" {
|
||
// Находим project_id, required_points и user_id для этого условия
|
||
var projectID int
|
||
var requiredPoints float64
|
||
var startDate sql.NullTime
|
||
var conditionOwnerID int
|
||
err = a.DB.QueryRow(`
|
||
SELECT sc.project_id, sc.required_points, sc.start_date, COALESCE(wc.user_id, $2)
|
||
FROM wishlist_conditions wc
|
||
JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||
WHERE wc.id = $1
|
||
`, condition.ID, userID).Scan(&projectID, &requiredPoints, &startDate, &conditionOwnerID)
|
||
if err == nil {
|
||
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, conditionOwnerID)
|
||
if err != nil {
|
||
// Если ошибка при расчете, устанавливаем 0
|
||
zeroScore := 0.0
|
||
condition.CurrentPoints = &zeroScore
|
||
conditionMet = false
|
||
} else {
|
||
condition.CurrentPoints = &totalScore
|
||
conditionMet = totalScore >= requiredPoints
|
||
}
|
||
}
|
||
}
|
||
|
||
if !conditionMet {
|
||
lockedCount++
|
||
if firstLocked == nil {
|
||
firstLocked = condition
|
||
}
|
||
}
|
||
}
|
||
if firstLocked != nil {
|
||
item.FirstLockedCondition = firstLocked
|
||
item.MoreLockedConditions = lockedCount - 1
|
||
item.LockedConditionsCount = lockedCount
|
||
}
|
||
} else {
|
||
// Даже если желание разблокировано, рассчитываем прогресс для всех условий
|
||
for i := range item.UnlockConditions {
|
||
condition := &item.UnlockConditions[i]
|
||
if condition.Type == "task_completion" {
|
||
var taskID int
|
||
var conditionOwnerID int
|
||
err := a.DB.QueryRow(`
|
||
SELECT tc.task_id, COALESCE(wc.user_id, $2)
|
||
FROM wishlist_conditions wc
|
||
JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||
WHERE wc.id = $1
|
||
`, condition.ID, userID).Scan(&taskID, &conditionOwnerID)
|
||
if err == nil {
|
||
var completed int
|
||
err = a.DB.QueryRow(`
|
||
SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE
|
||
`, taskID, conditionOwnerID).Scan(&completed)
|
||
if err == sql.ErrNoRows {
|
||
// Задача удалена или не существует - не блокируем желание
|
||
completedBool := true
|
||
condition.TaskCompleted = &completedBool
|
||
} else if err == nil {
|
||
completedBool := completed > 0
|
||
condition.TaskCompleted = &completedBool
|
||
}
|
||
}
|
||
} else if condition.Type == "project_points" {
|
||
var projectID int
|
||
var requiredPoints float64
|
||
var startDate sql.NullTime
|
||
var conditionOwnerID int
|
||
err := a.DB.QueryRow(`
|
||
SELECT sc.project_id, sc.required_points, sc.start_date, COALESCE(wc.user_id, $2)
|
||
FROM wishlist_conditions wc
|
||
JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||
WHERE wc.id = $1
|
||
`, condition.ID, userID).Scan(&projectID, &requiredPoints, &startDate, &conditionOwnerID)
|
||
if err == nil {
|
||
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, conditionOwnerID)
|
||
if err != nil {
|
||
// Если ошибка при расчете, устанавливаем 0
|
||
zeroScore := 0.0
|
||
condition.CurrentPoints = &zeroScore
|
||
} else {
|
||
condition.CurrentPoints = &totalScore
|
||
}
|
||
// Рассчитываем и форматируем срок разблокировки
|
||
if condition.ProjectID != nil && condition.RequiredPoints != nil {
|
||
weeks := a.calculateProjectUnlockWeeks(
|
||
projectID,
|
||
requiredPoints,
|
||
startDate,
|
||
conditionOwnerID,
|
||
)
|
||
weeksText := formatWeeksText(weeks)
|
||
condition.WeeksText = &weeksText
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Загружаем связанную задачу текущего пользователя, если есть
|
||
var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
|
||
var linkedTaskName sql.NullString
|
||
var linkedTaskNextShowAt sql.NullTime
|
||
linkedTaskErr := a.DB.QueryRow(`
|
||
SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id
|
||
FROM tasks t
|
||
WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE
|
||
LIMIT 1
|
||
`, item.ID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID)
|
||
|
||
if linkedTaskErr == nil && linkedTaskID.Valid {
|
||
linkedTask := &LinkedTask{
|
||
ID: int(linkedTaskID.Int64),
|
||
Name: linkedTaskName.String,
|
||
Completed: int(linkedTaskCompleted.Int64),
|
||
}
|
||
if linkedTaskNextShowAt.Valid {
|
||
nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339)
|
||
linkedTask.NextShowAt = &nextShowAtStr
|
||
}
|
||
if linkedTaskUserID.Valid {
|
||
userIDVal := int(linkedTaskUserID.Int64)
|
||
linkedTask.UserID = &userIDVal
|
||
}
|
||
item.LinkedTask = linkedTask
|
||
} else if linkedTaskErr != sql.ErrNoRows {
|
||
log.Printf("Error loading linked task for wishlist %d: %v", item.ID, linkedTaskErr)
|
||
// Не возвращаем ошибку, просто не устанавливаем linked_task
|
||
}
|
||
|
||
// Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей)
|
||
// Исключаем linked_task из подсчета, если она есть
|
||
// Учитываем только не закрытые задачи (completed = 0)
|
||
var tasksCount int
|
||
if linkedTaskID.Valid {
|
||
// Если есть linked_task, исключаем её из подсчета
|
||
err = a.DB.QueryRow(`
|
||
SELECT COUNT(*)
|
||
FROM tasks t
|
||
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2
|
||
`, item.ID, linkedTaskID.Int64).Scan(&tasksCount)
|
||
} else {
|
||
// Если нет linked_task, считаем все не закрытые задачи
|
||
err = a.DB.QueryRow(`
|
||
SELECT COUNT(*)
|
||
FROM tasks t
|
||
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0
|
||
`, item.ID).Scan(&tasksCount)
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error counting tasks for wishlist %d: %v", item.ID, err)
|
||
tasksCount = 0
|
||
}
|
||
item.TasksCount = tasksCount
|
||
|
||
items = append(items, *item)
|
||
}
|
||
|
||
return items, nil
|
||
}
|
||
|
||
// saveWishlistConditions сохраняет условия для желания
|
||
// userID - автор условий (пользователь, который создает/обновляет условия)
|
||
func (a *App) saveWishlistConditions(
|
||
tx *sql.Tx,
|
||
wishlistItemID int,
|
||
userID int,
|
||
conditions []UnlockConditionRequest,
|
||
) error {
|
||
// Получаем все существующие условия с их user_id перед удалением
|
||
existingConditions := make(map[int]int) // map[conditionID]userID
|
||
rows, err := tx.Query(`
|
||
SELECT id, user_id
|
||
FROM wishlist_conditions
|
||
WHERE wishlist_item_id = $1
|
||
`, wishlistItemID)
|
||
if err != nil {
|
||
return fmt.Errorf("error getting existing conditions: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var condID int
|
||
var condUserID sql.NullInt64
|
||
if err := rows.Scan(&condID, &condUserID); err != nil {
|
||
return fmt.Errorf("error scanning existing condition: %w", err)
|
||
}
|
||
if condUserID.Valid {
|
||
existingConditions[condID] = int(condUserID.Int64)
|
||
}
|
||
}
|
||
|
||
// Удаляем только условия текущего пользователя
|
||
_, err = tx.Exec(`
|
||
DELETE FROM wishlist_conditions
|
||
WHERE wishlist_item_id = $1 AND user_id = $2
|
||
`, wishlistItemID, userID)
|
||
if err != nil {
|
||
return fmt.Errorf("error deleting user conditions: %w", err)
|
||
}
|
||
|
||
if len(conditions) == 0 {
|
||
return nil
|
||
}
|
||
|
||
// Подготавливаем statement для вставки условий
|
||
stmt, err := tx.Prepare(`
|
||
INSERT INTO wishlist_conditions
|
||
(wishlist_item_id, user_id, task_condition_id, score_condition_id, display_order)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
`)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer stmt.Close()
|
||
|
||
for i, condition := range conditions {
|
||
displayOrder := i
|
||
if condition.DisplayOrder != nil {
|
||
displayOrder = *condition.DisplayOrder
|
||
}
|
||
|
||
var taskConditionID interface{}
|
||
var scoreConditionID interface{}
|
||
|
||
if condition.Type == "task_completion" {
|
||
if condition.TaskID == nil {
|
||
return fmt.Errorf("task_id is required for task_completion")
|
||
}
|
||
|
||
// Получаем или создаём task_condition
|
||
var tcID int
|
||
err := tx.QueryRow(`
|
||
SELECT id FROM task_conditions WHERE task_id = $1
|
||
`, *condition.TaskID).Scan(&tcID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
// Создаём новое условие
|
||
err = tx.QueryRow(`
|
||
INSERT INTO task_conditions (task_id)
|
||
VALUES ($1)
|
||
ON CONFLICT (task_id) DO UPDATE SET task_id = EXCLUDED.task_id
|
||
RETURNING id
|
||
`, *condition.TaskID).Scan(&tcID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
} else if err != nil {
|
||
return err
|
||
}
|
||
|
||
taskConditionID = tcID
|
||
|
||
} else if condition.Type == "project_points" {
|
||
if condition.ProjectID == nil || condition.RequiredPoints == nil {
|
||
return fmt.Errorf("project_id and required_points are required for project_points")
|
||
}
|
||
|
||
startDateStr := condition.StartDate
|
||
|
||
// Получаем или создаём score_condition
|
||
var scID int
|
||
var startDateVal interface{}
|
||
if startDateStr != nil && *startDateStr != "" {
|
||
// Парсим дату из строки YYYY-MM-DD
|
||
startDateVal = *startDateStr
|
||
} else {
|
||
// Пустая строка или nil = NULL для "за всё время"
|
||
startDateVal = nil
|
||
}
|
||
|
||
err := tx.QueryRow(`
|
||
SELECT id FROM score_conditions
|
||
WHERE project_id = $1
|
||
AND required_points = $2
|
||
AND (start_date = $3::DATE OR (start_date IS NULL AND $3 IS NULL))
|
||
`, *condition.ProjectID, *condition.RequiredPoints, startDateVal).Scan(&scID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
// Создаём новое условие
|
||
err = tx.QueryRow(`
|
||
INSERT INTO score_conditions (project_id, required_points, start_date)
|
||
VALUES ($1, $2, $3::DATE)
|
||
ON CONFLICT (project_id, required_points, start_date)
|
||
DO UPDATE SET project_id = EXCLUDED.project_id
|
||
RETURNING id
|
||
`, *condition.ProjectID, *condition.RequiredPoints, startDateVal).Scan(&scID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
} else if err != nil {
|
||
return err
|
||
}
|
||
|
||
scoreConditionID = scID
|
||
}
|
||
|
||
// Определяем user_id для условия:
|
||
// - Если условие имеет id и это условие существовало - проверяем, принадлежит ли оно текущему пользователю
|
||
// - Если условие принадлежит другому пользователю - пропускаем (не сохраняем, так как чужие условия не редактируются)
|
||
// - Если условие имеет id, но не существовало (например, было только что добавлено) - это новое условие, используем userID текущего пользователя
|
||
// - Если условие без id - это новое условие, используем userID текущего пользователя
|
||
conditionUserID := userID
|
||
if condition.ID != nil {
|
||
if originalUserID, exists := existingConditions[*condition.ID]; exists {
|
||
// Если условие принадлежит другому пользователю - пропускаем (не сохраняем, так как чужие условия не редактируются)
|
||
if originalUserID != userID {
|
||
continue
|
||
}
|
||
// Условие принадлежит текущему пользователю - обновляем его
|
||
conditionUserID = originalUserID
|
||
} else {
|
||
// Условие имеет id, но не существует в базе - это новое условие, используем userID текущего пользователя
|
||
conditionUserID = userID
|
||
}
|
||
}
|
||
|
||
// Создаём связь
|
||
_, err = stmt.Exec(
|
||
wishlistItemID,
|
||
conditionUserID,
|
||
taskConditionID,
|
||
scoreConditionID,
|
||
displayOrder,
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// getWishlistHandler возвращает список незавершённых желаний и счётчик завершённых
|
||
func (a *App) getWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Загружаем только незавершённые
|
||
items, err := a.getWishlistItemsWithConditions(userID, false)
|
||
if err != nil {
|
||
log.Printf("Error getting wishlist items: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist items: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Получаем количество завершённых
|
||
var completedCount int
|
||
err = a.DB.QueryRow(`
|
||
SELECT COUNT(*) FROM wishlist_items
|
||
WHERE user_id = $1 AND deleted = FALSE AND completed = TRUE
|
||
`, userID).Scan(&completedCount)
|
||
if err != nil {
|
||
log.Printf("Error counting completed wishlist items: %v", err)
|
||
completedCount = 0
|
||
}
|
||
|
||
// Группируем и сортируем
|
||
unlocked := make([]WishlistItem, 0)
|
||
locked := make([]WishlistItem, 0)
|
||
|
||
for _, item := range items {
|
||
if item.Unlocked {
|
||
unlocked = append(unlocked, item)
|
||
} else {
|
||
locked = append(locked, item)
|
||
}
|
||
}
|
||
|
||
// Сортируем разблокированные по цене от меньшего к большему
|
||
sort.Slice(unlocked, func(i, j int) bool {
|
||
priceI := 0.0
|
||
priceJ := 0.0
|
||
if unlocked[i].Price != nil {
|
||
priceI = *unlocked[i].Price
|
||
}
|
||
if unlocked[j].Price != nil {
|
||
priceJ = *unlocked[j].Price
|
||
}
|
||
if priceI == priceJ {
|
||
return unlocked[i].ID < unlocked[j].ID
|
||
}
|
||
return priceI < priceJ // Сортировка по цене от меньшего к большему (заменяет calculateUnlockedSortValue)
|
||
})
|
||
|
||
// Разделяем заблокированные на группы
|
||
lockedWithoutTasks := []WishlistItem{}
|
||
lockedWithTasks := []WishlistItem{}
|
||
|
||
for _, item := range locked {
|
||
hasUncompletedTasks := false
|
||
for _, cond := range item.UnlockConditions {
|
||
if cond.Type == "task_completion" && (cond.TaskCompleted == nil || !*cond.TaskCompleted) {
|
||
hasUncompletedTasks = true
|
||
break
|
||
}
|
||
}
|
||
if hasUncompletedTasks {
|
||
lockedWithTasks = append(lockedWithTasks, item)
|
||
} else {
|
||
lockedWithoutTasks = append(lockedWithoutTasks, item)
|
||
}
|
||
}
|
||
|
||
// Сортируем каждую группу по времени разблокировки (от меньшего срока к большему)
|
||
sort.Slice(lockedWithoutTasks, func(i, j int) bool {
|
||
valueI := a.calculateLockedSortValue(lockedWithoutTasks[i], userID)
|
||
valueJ := a.calculateLockedSortValue(lockedWithoutTasks[j], userID)
|
||
if valueI == valueJ {
|
||
return lockedWithoutTasks[i].ID < lockedWithoutTasks[j].ID
|
||
}
|
||
return valueI < valueJ
|
||
})
|
||
|
||
sort.Slice(lockedWithTasks, func(i, j int) bool {
|
||
valueI := a.calculateLockedSortValue(lockedWithTasks[i], userID)
|
||
valueJ := a.calculateLockedSortValue(lockedWithTasks[j], userID)
|
||
if valueI == valueJ {
|
||
return lockedWithTasks[i].ID < lockedWithTasks[j].ID
|
||
}
|
||
return valueI < valueJ
|
||
})
|
||
|
||
// Объединяем: сначала без задач, потом с задачами
|
||
locked = append(lockedWithoutTasks, lockedWithTasks...)
|
||
|
||
response := WishlistResponse{
|
||
Unlocked: unlocked,
|
||
Locked: locked,
|
||
CompletedCount: completedCount,
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
// getWishlistCompletedHandler возвращает список завершённых желаний
|
||
func (a *App) getWishlistCompletedHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
// Загружаем все желания включая завершённые
|
||
items, err := a.getWishlistItemsWithConditions(userID, true)
|
||
if err != nil {
|
||
log.Printf("Error getting completed wishlist items: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting completed wishlist items: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Фильтруем только завершённые
|
||
completed := make([]WishlistItem, 0)
|
||
for _, item := range items {
|
||
if item.Completed {
|
||
completed = append(completed, item)
|
||
}
|
||
}
|
||
|
||
// Сортируем по цене (дорогие → дешёвые)
|
||
sort.Slice(completed, func(i, j int) bool {
|
||
priceI := 0.0
|
||
priceJ := 0.0
|
||
if completed[i].Price != nil {
|
||
priceI = *completed[i].Price
|
||
}
|
||
if completed[j].Price != nil {
|
||
priceJ = *completed[j].Price
|
||
}
|
||
return priceI > priceJ
|
||
})
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(completed)
|
||
}
|
||
|
||
// createWishlistHandler создаёт новое желание
|
||
func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
log.Printf("createWishlistHandler: Unauthorized")
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
log.Printf("createWishlistHandler: userID=%d", userID)
|
||
|
||
var req WishlistRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("createWishlistHandler: Error decoding wishlist request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
log.Printf("createWishlistHandler: decoded request - name='%s', price=%v, link='%s', conditions=%d",
|
||
req.Name, req.Price, req.Link, len(req.UnlockConditions))
|
||
|
||
if req.UnlockConditions == nil {
|
||
log.Printf("createWishlistHandler: WARNING - UnlockConditions is nil, initializing empty slice")
|
||
req.UnlockConditions = []UnlockConditionRequest{}
|
||
}
|
||
|
||
for i, cond := range req.UnlockConditions {
|
||
log.Printf("createWishlistHandler: condition %d - type='%s', task_id=%v, project_id=%v, required_points=%v, start_date='%v'",
|
||
i, cond.Type, cond.TaskID, cond.ProjectID, cond.RequiredPoints, cond.StartDate)
|
||
}
|
||
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
log.Printf("createWishlistHandler: Name is required")
|
||
sendErrorWithCORS(w, "Name is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
var wishlistID int
|
||
err = tx.QueryRow(`
|
||
INSERT INTO wishlist_items (user_id, author_id, name, price, link, project_id, completed, deleted)
|
||
VALUES ($1, $1, $2, $3, $4, $5, FALSE, FALSE)
|
||
RETURNING id
|
||
`, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID).Scan(&wishlistID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating wishlist item: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist item: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Сохраняем условия
|
||
if len(req.UnlockConditions) > 0 {
|
||
log.Printf("createWishlistHandler: saving %d conditions", len(req.UnlockConditions))
|
||
err = a.saveWishlistConditionsWithUserID(tx, wishlistID, userID, req.UnlockConditions)
|
||
if err != nil {
|
||
log.Printf("createWishlistHandler: Error saving wishlist conditions: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
log.Printf("createWishlistHandler: conditions saved successfully")
|
||
} else {
|
||
log.Printf("createWishlistHandler: no conditions to save")
|
||
}
|
||
|
||
log.Printf("createWishlistHandler: committing transaction")
|
||
if err := tx.Commit(); err != nil {
|
||
log.Printf("createWishlistHandler: Error committing transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
log.Printf("createWishlistHandler: transaction committed successfully")
|
||
|
||
// Получаем созданное желание с условиями
|
||
items, err := a.getWishlistItemsWithConditions(userID, false)
|
||
if err != nil {
|
||
log.Printf("Error getting created wishlist item: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting created wishlist item: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
var createdItem *WishlistItem
|
||
for i := range items {
|
||
if items[i].ID == wishlistID {
|
||
createdItem = &items[i]
|
||
break
|
||
}
|
||
}
|
||
|
||
if createdItem == nil {
|
||
log.Printf("createWishlistHandler: Created item not found")
|
||
sendErrorWithCORS(w, "Created item not found", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
log.Printf("createWishlistHandler: Successfully created wishlist item id=%d, name='%s'",
|
||
createdItem.ID, createdItem.Name)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(createdItem)
|
||
}
|
||
|
||
// checkWishlistAccess проверяет доступ пользователя к желанию
|
||
// Возвращает (hasAccess, itemUserID, boardID, error)
|
||
func (a *App) checkWishlistAccess(itemID int, userID int) (bool, int, sql.NullInt64, error) {
|
||
var itemUserID int
|
||
var boardID sql.NullInt64
|
||
err := a.DB.QueryRow(`
|
||
SELECT user_id, board_id
|
||
FROM wishlist_items
|
||
WHERE id = $1 AND deleted = FALSE
|
||
`, itemID).Scan(&itemUserID, &boardID)
|
||
|
||
if err == sql.ErrNoRows {
|
||
return false, 0, sql.NullInt64{}, err
|
||
}
|
||
if err != nil {
|
||
return false, 0, sql.NullInt64{}, err
|
||
}
|
||
|
||
// Проверяем доступ: владелец ИЛИ участник доски
|
||
hasAccess := itemUserID == userID
|
||
if !hasAccess && boardID.Valid {
|
||
var ownerID int
|
||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID.Int64).Scan(&ownerID)
|
||
if err == nil {
|
||
hasAccess = ownerID == userID
|
||
if !hasAccess {
|
||
var isMember bool
|
||
err = a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`,
|
||
int(boardID.Int64), userID).Scan(&isMember)
|
||
if err == nil {
|
||
hasAccess = isMember
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return hasAccess, itemUserID, boardID, nil
|
||
}
|
||
|
||
// CalculateWeeksRequest структура запроса для расчета недель
|
||
type CalculateWeeksRequest struct {
|
||
ProjectID int `json:"project_id"`
|
||
RequiredPoints float64 `json:"required_points"`
|
||
StartDate string `json:"start_date,omitempty"`
|
||
ConditionUserID *int `json:"condition_user_id,omitempty"` // Владелец условия (если условие существует)
|
||
}
|
||
|
||
// calculateWeeksHandler обрабатывает запрос на расчет недель для разблокировки условия
|
||
func (a *App) calculateWeeksHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req CalculateWeeksRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Определяем владельца условия:
|
||
// 1. Если передан condition_user_id в запросе - используем его (для существующего условия)
|
||
// 2. Иначе используем текущего пользователя (для нового условия)
|
||
conditionOwnerID := userID // userID из контекста (текущий пользователь)
|
||
if req.ConditionUserID != nil && *req.ConditionUserID > 0 {
|
||
conditionOwnerID = *req.ConditionUserID
|
||
}
|
||
|
||
var startDate sql.NullTime
|
||
if req.StartDate != "" {
|
||
date, err := time.Parse("2006-01-02", req.StartDate)
|
||
if err == nil {
|
||
startDate = sql.NullTime{Time: date, Valid: true}
|
||
}
|
||
}
|
||
|
||
// Используем владельца условия, а не текущего пользователя
|
||
weeks := a.calculateProjectUnlockWeeks(req.ProjectID, req.RequiredPoints, startDate, conditionOwnerID)
|
||
|
||
response := map[string]interface{}{
|
||
"weeks_text": formatWeeksText(weeks), // Отформатированная строка для отображения
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
// getWishlistItemHandler возвращает одно желание
|
||
func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
itemID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем доступ к желанию
|
||
hasAccess, itemUserID, boardID, err := a.checkWishlistAccess(itemID, userID)
|
||
if err == sql.ErrNoRows {
|
||
log.Printf("Wishlist item not found: id=%d, userID=%d", itemID, userID)
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error getting wishlist item (id=%d, userID=%d): %v", itemID, userID, err)
|
||
sendErrorWithCORS(w, "Error getting wishlist item", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
log.Printf("Wishlist item found: id=%d, itemUserID=%d, boardID=%v, currentUserID=%d", itemID, itemUserID, boardID, userID)
|
||
|
||
if !hasAccess {
|
||
log.Printf("Access denied for wishlist item: id=%d, itemUserID=%d, boardID=%v, currentUserID=%d", itemID, itemUserID, boardID, userID)
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
log.Printf("Access granted for wishlist item: id=%d, itemUserID=%d, boardID=%v, currentUserID=%d", itemID, itemUserID, boardID, userID)
|
||
|
||
// Сохраняем itemUserID для использования в качестве fallback, если conditionUserID NULL
|
||
itemOwnerID := itemUserID
|
||
|
||
// Загружаем полную информацию о желании
|
||
query := `
|
||
SELECT
|
||
wi.id,
|
||
wi.name,
|
||
wi.price,
|
||
wi.image_path,
|
||
wi.link,
|
||
wi.completed,
|
||
wi.project_id AS item_project_id,
|
||
wp.name AS item_project_name,
|
||
wc.id AS condition_id,
|
||
wc.display_order,
|
||
wc.task_condition_id,
|
||
wc.score_condition_id,
|
||
wc.user_id AS condition_user_id,
|
||
tc.task_id,
|
||
t.name AS task_name,
|
||
t.next_show_at AS task_next_show_at,
|
||
sc.project_id,
|
||
p.name AS project_name,
|
||
sc.required_points,
|
||
sc.start_date
|
||
FROM wishlist_items wi
|
||
LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE
|
||
LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id
|
||
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||
LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE
|
||
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||
LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE
|
||
WHERE wi.id = $1
|
||
AND wi.deleted = FALSE
|
||
ORDER BY wc.display_order, wc.id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, itemID)
|
||
if err != nil {
|
||
log.Printf("Error querying wishlist item: %v", err)
|
||
sendErrorWithCORS(w, "Error getting wishlist item", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
itemsMap := make(map[int]*WishlistItem)
|
||
for rows.Next() {
|
||
var itemID int
|
||
var name string
|
||
var price sql.NullFloat64
|
||
var imagePath sql.NullString
|
||
var link sql.NullString
|
||
var completed bool
|
||
var itemProjectID sql.NullInt64
|
||
var itemProjectName sql.NullString
|
||
var conditionID sql.NullInt64
|
||
var displayOrder sql.NullInt64
|
||
var taskConditionID sql.NullInt64
|
||
var scoreConditionID sql.NullInt64
|
||
var conditionUserID sql.NullInt64
|
||
var taskID sql.NullInt64
|
||
var taskName sql.NullString
|
||
var taskNextShowAt sql.NullTime
|
||
var projectID sql.NullInt64
|
||
var projectName sql.NullString
|
||
var requiredPoints sql.NullFloat64
|
||
var startDate sql.NullTime
|
||
|
||
err := rows.Scan(
|
||
&itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName,
|
||
&conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID,
|
||
&taskID, &taskName, &taskNextShowAt, &projectID, &projectName, &requiredPoints, &startDate,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning wishlist item: %v", err)
|
||
continue
|
||
}
|
||
|
||
item, exists := itemsMap[itemID]
|
||
if !exists {
|
||
item = &WishlistItem{
|
||
ID: itemID,
|
||
Name: name,
|
||
Completed: completed,
|
||
UnlockConditions: []UnlockConditionDisplay{},
|
||
}
|
||
if price.Valid {
|
||
item.Price = &price.Float64
|
||
}
|
||
if imagePath.Valid && imagePath.String != "" {
|
||
url := imagePath.String
|
||
if !strings.HasPrefix(url, "http") {
|
||
url = url + "?t=" + strconv.FormatInt(time.Now().Unix(), 10)
|
||
}
|
||
item.ImageURL = &url
|
||
}
|
||
if link.Valid {
|
||
item.Link = &link.String
|
||
}
|
||
if itemProjectID.Valid {
|
||
projectIDVal := int(itemProjectID.Int64)
|
||
item.ProjectID = &projectIDVal
|
||
}
|
||
if itemProjectName.Valid {
|
||
projectNameVal := itemProjectName.String
|
||
item.ProjectName = &projectNameVal
|
||
}
|
||
itemsMap[itemID] = item
|
||
}
|
||
|
||
if conditionID.Valid {
|
||
// Используем user_id из условия, если он есть, иначе используем владельца желания
|
||
// Это важно для старых условий, созданных до добавления user_id в wishlist_conditions
|
||
conditionOwnerID := itemOwnerID
|
||
if conditionUserID.Valid {
|
||
conditionOwnerID = int(conditionUserID.Int64)
|
||
}
|
||
|
||
// Если это условие по задаче, проверяем существует ли задача
|
||
if taskConditionID.Valid && taskID.Valid {
|
||
// Проверяем, существует ли задача (не удалена)
|
||
var taskExists bool
|
||
err := a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)`, taskID.Int64, conditionOwnerID).Scan(&taskExists)
|
||
if err != nil || !taskExists {
|
||
// Задача удалена - не добавляем условие в список, но при проверке блокировки оно считается выполненным
|
||
continue
|
||
}
|
||
}
|
||
|
||
condition := UnlockConditionDisplay{
|
||
ID: int(conditionID.Int64),
|
||
DisplayOrder: int(displayOrder.Int64),
|
||
}
|
||
|
||
if conditionUserID.Valid {
|
||
conditionOwnerID := int(conditionUserID.Int64)
|
||
condition.UserID = &conditionOwnerID
|
||
} else {
|
||
condition.UserID = &itemOwnerID
|
||
}
|
||
|
||
if taskConditionID.Valid {
|
||
condition.Type = "task_completion"
|
||
if taskName.Valid {
|
||
condition.TaskName = &taskName.String
|
||
}
|
||
if taskID.Valid {
|
||
taskIDVal := int(taskID.Int64)
|
||
condition.TaskID = &taskIDVal
|
||
var taskCompleted int
|
||
err := a.DB.QueryRow(`SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE`, taskID.Int64, conditionOwnerID).Scan(&taskCompleted)
|
||
if err == nil {
|
||
isCompleted := taskCompleted > 0
|
||
condition.TaskCompleted = &isCompleted
|
||
}
|
||
}
|
||
if taskNextShowAt.Valid {
|
||
nextShowAtStr := taskNextShowAt.Time.Format(time.RFC3339)
|
||
condition.TaskNextShowAt = &nextShowAtStr
|
||
}
|
||
} else if scoreConditionID.Valid {
|
||
condition.Type = "project_points"
|
||
if projectName.Valid {
|
||
condition.ProjectName = &projectName.String
|
||
}
|
||
if projectID.Valid {
|
||
projectIDVal := int(projectID.Int64)
|
||
condition.ProjectID = &projectIDVal
|
||
points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID)
|
||
condition.CurrentPoints = &points
|
||
}
|
||
if requiredPoints.Valid {
|
||
condition.RequiredPoints = &requiredPoints.Float64
|
||
}
|
||
if startDate.Valid {
|
||
dateStr := startDate.Time.Format("2006-01-02")
|
||
condition.StartDate = &dateStr
|
||
}
|
||
// Рассчитываем и форматируем срок разблокировки
|
||
if condition.ProjectID != nil && condition.RequiredPoints != nil {
|
||
weeks := a.calculateProjectUnlockWeeks(
|
||
*condition.ProjectID,
|
||
*condition.RequiredPoints,
|
||
startDate,
|
||
conditionOwnerID,
|
||
)
|
||
weeksText := formatWeeksText(weeks)
|
||
condition.WeeksText = &weeksText
|
||
}
|
||
}
|
||
|
||
item.UnlockConditions = append(item.UnlockConditions, condition)
|
||
}
|
||
}
|
||
|
||
// Получаем желание из map
|
||
var item *WishlistItem
|
||
for _, it := range itemsMap {
|
||
if it.ID == itemID {
|
||
item = it
|
||
break
|
||
}
|
||
}
|
||
|
||
if item == nil {
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
// Проверяем разблокировку
|
||
item.Unlocked = true
|
||
if len(item.UnlockConditions) > 0 {
|
||
for _, cond := range item.UnlockConditions {
|
||
if cond.Type == "task_completion" {
|
||
if cond.TaskCompleted == nil || !*cond.TaskCompleted {
|
||
item.Unlocked = false
|
||
break
|
||
}
|
||
} else if cond.Type == "project_points" {
|
||
if cond.CurrentPoints == nil || cond.RequiredPoints == nil || *cond.CurrentPoints < *cond.RequiredPoints {
|
||
item.Unlocked = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Также проверяем через checkWishlistUnlock для совместимости
|
||
unlocked, err := a.checkWishlistUnlock(itemID, userID)
|
||
if err == nil {
|
||
item.Unlocked = unlocked
|
||
}
|
||
|
||
// Сортируем условия в нужном порядке
|
||
a.sortUnlockConditions(item.UnlockConditions, userID)
|
||
|
||
// Загружаем связанную задачу текущего пользователя, если есть
|
||
var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
|
||
var linkedTaskName sql.NullString
|
||
var linkedTaskNextShowAt sql.NullTime
|
||
err = a.DB.QueryRow(`
|
||
SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id
|
||
FROM tasks t
|
||
WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE
|
||
LIMIT 1
|
||
`, itemID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID)
|
||
|
||
if err == nil && linkedTaskID.Valid {
|
||
linkedTask := &LinkedTask{
|
||
ID: int(linkedTaskID.Int64),
|
||
Name: linkedTaskName.String,
|
||
Completed: int(linkedTaskCompleted.Int64),
|
||
}
|
||
if linkedTaskNextShowAt.Valid {
|
||
nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339)
|
||
linkedTask.NextShowAt = &nextShowAtStr
|
||
}
|
||
if linkedTaskUserID.Valid {
|
||
userIDVal := int(linkedTaskUserID.Int64)
|
||
linkedTask.UserID = &userIDVal
|
||
}
|
||
item.LinkedTask = linkedTask
|
||
} else if err != sql.ErrNoRows {
|
||
log.Printf("Error loading linked task for wishlist %d: %v", itemID, err)
|
||
// Не возвращаем ошибку, просто не устанавливаем linked_task
|
||
}
|
||
|
||
// Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей)
|
||
// Исключаем linked_task из подсчета, если она есть
|
||
// Учитываем только не закрытые задачи (completed = 0)
|
||
var tasksCount int
|
||
if linkedTaskID.Valid {
|
||
// Если есть linked_task, исключаем её из подсчета
|
||
err = a.DB.QueryRow(`
|
||
SELECT COUNT(*)
|
||
FROM tasks t
|
||
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2
|
||
`, itemID, linkedTaskID.Int64).Scan(&tasksCount)
|
||
} else {
|
||
// Если нет linked_task, считаем все не закрытые задачи
|
||
err = a.DB.QueryRow(`
|
||
SELECT COUNT(*)
|
||
FROM tasks t
|
||
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0
|
||
`, itemID).Scan(&tasksCount)
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error counting tasks for wishlist %d: %v", itemID, err)
|
||
tasksCount = 0
|
||
}
|
||
item.TasksCount = tasksCount
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(item)
|
||
}
|
||
|
||
// updateWishlistHandler обновляет желание
|
||
func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
log.Printf("updateWishlistHandler called: method=%s, path=%s", r.Method, r.URL.Path)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
log.Printf("updateWishlistHandler: Unauthorized")
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
itemID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
log.Printf("updateWishlistHandler: Invalid wishlist ID: %v", err)
|
||
sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
log.Printf("updateWishlistHandler: itemID=%d, userID=%d", itemID, userID)
|
||
|
||
// Проверяем доступ к желанию
|
||
hasAccess, _, _, err := a.checkWishlistAccess(itemID, userID)
|
||
if err == sql.ErrNoRows {
|
||
log.Printf("updateWishlistHandler: Wishlist item not found: id=%d, userID=%d", itemID, userID)
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("updateWishlistHandler: Error getting wishlist item (id=%d, userID=%d): %v", itemID, userID, err)
|
||
sendErrorWithCORS(w, "Error getting wishlist item", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if !hasAccess {
|
||
log.Printf("updateWishlistHandler: Access denied: id=%d, userID=%d", itemID, userID)
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
log.Printf("updateWishlistHandler: Access granted: id=%d, userID=%d", itemID, userID)
|
||
|
||
var req WishlistRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding wishlist request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
sendErrorWithCORS(w, "Name is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Обновляем желание (не проверяем user_id в WHERE, так как доступ уже проверен выше)
|
||
_, err = tx.Exec(`
|
||
UPDATE wishlist_items
|
||
SET name = $1, price = $2, link = $3, project_id = $4, updated_at = NOW()
|
||
WHERE id = $5
|
||
`, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID, itemID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error updating wishlist item: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error updating wishlist item: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Сохраняем условия
|
||
err = a.saveWishlistConditions(tx, itemID, userID, req.UnlockConditions)
|
||
if err != nil {
|
||
log.Printf("Error saving wishlist conditions: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
log.Printf("Error committing transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Получаем обновлённое желание через 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
|
||
LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE
|
||
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||
LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE
|
||
WHERE wi.id = $1
|
||
AND wi.deleted = FALSE
|
||
ORDER BY wc.display_order, wc.id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, itemID)
|
||
if err != nil {
|
||
log.Printf("Error querying updated wishlist item: %v", err)
|
||
sendErrorWithCORS(w, "Error getting updated wishlist item", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
itemsMap := make(map[int]*WishlistItem)
|
||
var itemOwnerID int
|
||
for rows.Next() {
|
||
var itemID int
|
||
var name string
|
||
var price sql.NullFloat64
|
||
var imagePath sql.NullString
|
||
var link sql.NullString
|
||
var completed bool
|
||
var 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
|
||
}
|
||
|
||
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 {
|
||
// Определяем владельца условия
|
||
conditionOwnerID := itemOwnerID
|
||
if conditionUserID.Valid {
|
||
conditionOwnerID = int(conditionUserID.Int64)
|
||
}
|
||
|
||
// Если это условие по задаче, проверяем существует ли задача
|
||
if taskConditionID.Valid && taskID.Valid {
|
||
// Проверяем, существует ли задача (не удалена)
|
||
var taskExists bool
|
||
err := a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)`, taskID.Int64, conditionOwnerID).Scan(&taskExists)
|
||
if err != nil || !taskExists {
|
||
// Задача удалена - не добавляем условие в список, но при проверке блокировки оно считается выполненным
|
||
continue
|
||
}
|
||
}
|
||
|
||
condition := UnlockConditionDisplay{
|
||
ID: int(conditionID.Int64),
|
||
DisplayOrder: int(displayOrder.Int64),
|
||
}
|
||
|
||
if conditionUserID.Valid {
|
||
conditionOwnerID := int(conditionUserID.Int64)
|
||
condition.UserID = &conditionOwnerID
|
||
} else {
|
||
condition.UserID = &itemOwnerID
|
||
}
|
||
|
||
if taskConditionID.Valid {
|
||
condition.Type = "task_completion"
|
||
if taskName.Valid {
|
||
condition.TaskName = &taskName.String
|
||
}
|
||
if taskID.Valid {
|
||
taskIDVal := int(taskID.Int64)
|
||
condition.TaskID = &taskIDVal
|
||
var taskCompleted int
|
||
err := a.DB.QueryRow(`SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE`, taskID.Int64, conditionOwnerID).Scan(&taskCompleted)
|
||
if err == nil {
|
||
isCompleted := taskCompleted > 0
|
||
condition.TaskCompleted = &isCompleted
|
||
}
|
||
}
|
||
} else if scoreConditionID.Valid {
|
||
condition.Type = "project_points"
|
||
if projectName.Valid {
|
||
condition.ProjectName = &projectName.String
|
||
}
|
||
if projectID.Valid {
|
||
projectIDVal := int(projectID.Int64)
|
||
condition.ProjectID = &projectIDVal
|
||
points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID)
|
||
condition.CurrentPoints = &points
|
||
}
|
||
if requiredPoints.Valid {
|
||
condition.RequiredPoints = &requiredPoints.Float64
|
||
}
|
||
if startDate.Valid {
|
||
dateStr := startDate.Time.Format("2006-01-02")
|
||
condition.StartDate = &dateStr
|
||
}
|
||
// Рассчитываем и форматируем срок разблокировки
|
||
if condition.ProjectID != nil && condition.RequiredPoints != nil {
|
||
weeks := a.calculateProjectUnlockWeeks(
|
||
*condition.ProjectID,
|
||
*condition.RequiredPoints,
|
||
startDate,
|
||
conditionOwnerID,
|
||
)
|
||
weeksText := formatWeeksText(weeks)
|
||
condition.WeeksText = &weeksText
|
||
}
|
||
}
|
||
|
||
item.UnlockConditions = append(item.UnlockConditions, condition)
|
||
}
|
||
}
|
||
|
||
var updatedItem *WishlistItem
|
||
for _, it := range itemsMap {
|
||
if it.ID == itemID {
|
||
updatedItem = it
|
||
break
|
||
}
|
||
}
|
||
|
||
if updatedItem == nil {
|
||
log.Printf("Updated item not found: id=%d", itemID)
|
||
sendErrorWithCORS(w, "Updated item not found", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Проверяем разблокировку
|
||
updatedItem.Unlocked = true
|
||
if len(updatedItem.UnlockConditions) > 0 {
|
||
for _, cond := range updatedItem.UnlockConditions {
|
||
if cond.Type == "task_completion" {
|
||
if cond.TaskCompleted == nil || !*cond.TaskCompleted {
|
||
updatedItem.Unlocked = false
|
||
break
|
||
}
|
||
} else if cond.Type == "project_points" {
|
||
if cond.CurrentPoints == nil || cond.RequiredPoints == nil || *cond.CurrentPoints < *cond.RequiredPoints {
|
||
updatedItem.Unlocked = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
unlocked, err := a.checkWishlistUnlock(itemID, userID)
|
||
if err == nil {
|
||
updatedItem.Unlocked = unlocked
|
||
}
|
||
|
||
// Сортируем условия в нужном порядке
|
||
a.sortUnlockConditions(updatedItem.UnlockConditions, userID)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(updatedItem)
|
||
}
|
||
|
||
// deleteWishlistHandler удаляет желание (soft delete)
|
||
func (a *App) deleteWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
itemID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем доступ к желанию
|
||
hasAccess, _, _, err := a.checkWishlistAccess(itemID, userID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking wishlist access: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !hasAccess {
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
_, err = a.DB.Exec(`
|
||
UPDATE wishlist_items
|
||
SET deleted = TRUE, updated_at = NOW()
|
||
WHERE id = $1
|
||
`, itemID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error deleting wishlist item: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error deleting wishlist item: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Wishlist item deleted successfully",
|
||
})
|
||
}
|
||
|
||
// uploadWishlistImageHandler загружает картинку для желания
|
||
func (a *App) uploadWishlistImageHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
wishlistID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем доступ к желанию
|
||
hasAccess, _, _, err := a.checkWishlistAccess(wishlistID, userID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking wishlist access: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !hasAccess {
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Парсим multipart form (макс 5MB)
|
||
err = r.ParseMultipartForm(5 << 20)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "File too large (max 5MB)", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
file, _, err := r.FormFile("image")
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Error retrieving file", http.StatusBadRequest)
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
// Декодируем изображение
|
||
img, err := imaging.Decode(file)
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid image format", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Сжимаем до максимальной ширины 1200px (сохраняя пропорции)
|
||
if img.Bounds().Dx() > 1200 {
|
||
img = imaging.Resize(img, 1200, 0, imaging.Lanczos)
|
||
}
|
||
|
||
// Создаём директорию
|
||
uploadDir := fmt.Sprintf("/app/uploads/wishlist/%d", userID)
|
||
err = os.MkdirAll(uploadDir, 0755)
|
||
if err != nil {
|
||
log.Printf("Error creating directory: %v", err)
|
||
sendErrorWithCORS(w, "Error creating directory", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Генерируем уникальное имя файла
|
||
randomBytes := make([]byte, 8)
|
||
rand.Read(randomBytes)
|
||
filename := fmt.Sprintf("%d_%x.jpg", wishlistID, randomBytes)
|
||
filepath := filepath.Join(uploadDir, filename)
|
||
|
||
dst, err := os.Create(filepath)
|
||
if err != nil {
|
||
log.Printf("Error creating file: %v", err)
|
||
sendErrorWithCORS(w, "Error saving file", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer dst.Close()
|
||
|
||
// Кодируем в JPEG с качеством 85%
|
||
err = jpeg.Encode(dst, img, &jpeg.Options{Quality: 85})
|
||
if err != nil {
|
||
log.Printf("Error encoding image: %v", err)
|
||
sendErrorWithCORS(w, "Error encoding image", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Обновляем путь в БД (уникальное имя файла уже обеспечивает сброс кэша)
|
||
imagePath := fmt.Sprintf("/uploads/wishlist/%d/%s", userID, filename)
|
||
_, err = a.DB.Exec(`
|
||
UPDATE wishlist_items
|
||
SET image_path = $1, updated_at = NOW()
|
||
WHERE id = $2
|
||
`, imagePath, wishlistID)
|
||
if err != nil {
|
||
log.Printf("Error updating database: %v", err)
|
||
sendErrorWithCORS(w, "Error updating database", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{
|
||
"image_url": imagePath,
|
||
})
|
||
}
|
||
|
||
// deleteWishlistImageHandler удаляет картинку желания
|
||
func (a *App) deleteWishlistImageHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
wishlistID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем доступ к желанию
|
||
hasAccess, _, _, err := a.checkWishlistAccess(wishlistID, userID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking wishlist access: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !hasAccess {
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Получаем текущий путь к изображению из БД
|
||
var currentImagePath sql.NullString
|
||
err = a.DB.QueryRow(`
|
||
SELECT image_path
|
||
FROM wishlist_items
|
||
WHERE id = $1
|
||
`, wishlistID).Scan(¤tImagePath)
|
||
|
||
if err != nil {
|
||
log.Printf("Error getting image path: %v", err)
|
||
sendErrorWithCORS(w, "Error getting image path", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Удаляем файл, если он существует
|
||
if currentImagePath.Valid && currentImagePath.String != "" {
|
||
filePath := filepath.Join("/app", currentImagePath.String)
|
||
err = os.Remove(filePath)
|
||
if err != nil && !os.IsNotExist(err) {
|
||
log.Printf("Error deleting image file: %v", err)
|
||
// Продолжаем выполнение даже если файл не найден
|
||
}
|
||
}
|
||
|
||
// Обновляем БД, устанавливая image_path в NULL
|
||
_, err = a.DB.Exec(`
|
||
UPDATE wishlist_items
|
||
SET image_path = NULL, updated_at = NOW()
|
||
WHERE id = $1
|
||
`, wishlistID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error updating database: %v", err)
|
||
sendErrorWithCORS(w, "Error updating database", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Image deleted successfully",
|
||
})
|
||
}
|
||
|
||
// completeWishlistHandler помечает желание как завершённое
|
||
func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
itemID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем доступ к желанию
|
||
hasAccess, _, _, err := a.checkWishlistAccess(itemID, userID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking wishlist access: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !hasAccess {
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
_, err = a.DB.Exec(`
|
||
UPDATE wishlist_items
|
||
SET completed = TRUE, updated_at = NOW()
|
||
WHERE id = $1
|
||
`, itemID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error completing wishlist item: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error completing wishlist item: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Находим задачу пользователя для этого желания, чтобы исключить её из обработки
|
||
// (так же, как при закрытии через задачу)
|
||
var userTaskID int
|
||
err = a.DB.QueryRow(`
|
||
SELECT id FROM tasks
|
||
WHERE wishlist_id = $1 AND user_id = $2 AND deleted = FALSE
|
||
LIMIT 1
|
||
`, itemID, userID).Scan(&userTaskID)
|
||
|
||
// Если задача не найдена, используем 0 (не будет исключена, но это нормально, если задачи нет)
|
||
if err == sql.ErrNoRows {
|
||
userTaskID = 0
|
||
} else if err != nil {
|
||
log.Printf("Error finding user task for wishlist item %d: %v", itemID, err)
|
||
userTaskID = 0
|
||
}
|
||
|
||
// Обрабатываем политику награждения для всех задач, связанных с этим желанием
|
||
// Исключаем задачу пользователя, который закрыл желание (если она есть)
|
||
a.processWishlistRewardPolicy(itemID, userTaskID)
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Wishlist item completed successfully",
|
||
})
|
||
}
|
||
|
||
// processWishlistRewardPolicy обрабатывает политику награждения для всех задач, связанных с желанием
|
||
// completedTaskID - ID задачи, которая была закрыта (исключается из обработки). Если 0, задача не найдена, но это нормально
|
||
func (a *App) processWishlistRewardPolicy(wishlistItemID int, completedTaskID int) {
|
||
var rows *sql.Rows
|
||
var err error
|
||
if completedTaskID == 0 {
|
||
// Если задача не найдена (желание закрывается напрямую, но у пользователя нет задачи),
|
||
// обрабатываем все задачи
|
||
rows, err = a.DB.Query(`
|
||
SELECT id, user_id, reward_policy
|
||
FROM tasks
|
||
WHERE wishlist_id = $1 AND deleted = FALSE
|
||
`, wishlistItemID)
|
||
} else {
|
||
// Исключаем задачу, которая была закрыта (через задачу или найдена при прямом закрытии желания)
|
||
rows, err = a.DB.Query(`
|
||
SELECT id, user_id, reward_policy
|
||
FROM tasks
|
||
WHERE wishlist_id = $1 AND deleted = FALSE AND id != $2
|
||
`, wishlistItemID, completedTaskID)
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error querying tasks for wishlist item %d: %v", wishlistItemID, err)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var taskID, taskUserID int
|
||
var rewardPolicy sql.NullString
|
||
err := rows.Scan(&taskID, &taskUserID, &rewardPolicy)
|
||
if err != nil {
|
||
log.Printf("Error scanning task: %v", err)
|
||
continue
|
||
}
|
||
|
||
policy := "personal" // Значение по умолчанию
|
||
if rewardPolicy.Valid {
|
||
policy = rewardPolicy.String
|
||
}
|
||
|
||
if policy == "personal" {
|
||
// Личная политика: при закрытии задачи-желания другим пользователем, личная задача удаляется
|
||
_, err = a.DB.Exec(`
|
||
UPDATE tasks
|
||
SET deleted = TRUE
|
||
WHERE id = $1
|
||
`, taskID)
|
||
if err != nil {
|
||
log.Printf("Error deleting task %d: %v", taskID, err)
|
||
} else {
|
||
log.Printf("Task %d deleted because wishlist item %d was completed by another user (personal policy)", taskID, wishlistItemID)
|
||
}
|
||
} else if policy == "general" {
|
||
// Общая политика: при закрытии задачи-желания другим пользователем, общая задача закрывается
|
||
_, err = a.DB.Exec(`
|
||
UPDATE tasks
|
||
SET completed = completed + 1, last_completed_at = NOW()
|
||
WHERE id = $1
|
||
`, taskID)
|
||
if err != nil {
|
||
log.Printf("Error completing task %d: %v", taskID, err)
|
||
} else {
|
||
log.Printf("Task %d completed automatically after wishlist item %d completion (general policy)", taskID, wishlistItemID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// uncompleteWishlistHandler снимает отметку завершения
|
||
func (a *App) uncompleteWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
itemID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем доступ к желанию
|
||
hasAccess, _, _, err := a.checkWishlistAccess(itemID, userID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error checking wishlist access: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist access: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
if !hasAccess {
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
_, err = a.DB.Exec(`
|
||
UPDATE wishlist_items
|
||
SET completed = FALSE, updated_at = NOW()
|
||
WHERE id = $1
|
||
`, itemID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error uncompleting wishlist item: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error uncompleting wishlist item: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||
"success": true,
|
||
"message": "Wishlist item uncompleted successfully",
|
||
})
|
||
}
|
||
|
||
// copyWishlistHandler копирует желание
|
||
func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
itemID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Получаем оригинальное желание
|
||
var name string
|
||
var price sql.NullFloat64
|
||
var link sql.NullString
|
||
var imagePath sql.NullString
|
||
var ownerID int
|
||
var boardID sql.NullInt64
|
||
var authorID sql.NullInt64
|
||
err = a.DB.QueryRow(`
|
||
SELECT user_id, name, price, link, image_path, board_id, author_id
|
||
FROM wishlist_items
|
||
WHERE id = $1 AND deleted = FALSE
|
||
`, itemID).Scan(&ownerID, &name, &price, &link, &imagePath, &boardID, &authorID)
|
||
|
||
if err == sql.ErrNoRows || ownerID != userID {
|
||
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error getting wishlist item: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist item: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Получаем условия оригинального желания
|
||
rows, err := a.DB.Query(`
|
||
SELECT
|
||
wc.display_order,
|
||
wc.task_condition_id,
|
||
wc.score_condition_id,
|
||
tc.task_id,
|
||
sc.project_id,
|
||
sc.required_points,
|
||
sc.start_date
|
||
FROM wishlist_conditions wc
|
||
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||
WHERE wc.wishlist_item_id = $1
|
||
ORDER BY wc.display_order
|
||
`, itemID)
|
||
if err != nil {
|
||
log.Printf("Error getting wishlist conditions: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist conditions: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
var conditions []UnlockConditionRequest
|
||
for rows.Next() {
|
||
var displayOrder int
|
||
var taskConditionID, scoreConditionID sql.NullInt64
|
||
var taskID, projectID sql.NullInt64
|
||
var requiredPoints sql.NullFloat64
|
||
var startDate sql.NullString
|
||
|
||
err := rows.Scan(&displayOrder, &taskConditionID, &scoreConditionID, &taskID, &projectID, &requiredPoints, &startDate)
|
||
if err != nil {
|
||
log.Printf("Error scanning condition row: %v", err)
|
||
continue
|
||
}
|
||
|
||
cond := UnlockConditionRequest{
|
||
DisplayOrder: &displayOrder,
|
||
}
|
||
|
||
if taskConditionID.Valid && taskID.Valid {
|
||
cond.Type = "task_completion"
|
||
tid := int(taskID.Int64)
|
||
cond.TaskID = &tid
|
||
} else if scoreConditionID.Valid && projectID.Valid {
|
||
cond.Type = "project_points"
|
||
pid := int(projectID.Int64)
|
||
cond.ProjectID = &pid
|
||
if requiredPoints.Valid {
|
||
cond.RequiredPoints = &requiredPoints.Float64
|
||
}
|
||
if startDate.Valid {
|
||
cond.StartDate = &startDate.String
|
||
}
|
||
}
|
||
|
||
conditions = append(conditions, cond)
|
||
}
|
||
|
||
// Создаём копию в транзакции
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error beginning transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
// Создаём копию желания
|
||
var newWishlistID int
|
||
var priceVal, linkVal interface{}
|
||
if price.Valid {
|
||
priceVal = price.Float64
|
||
}
|
||
if link.Valid {
|
||
linkVal = link.String
|
||
}
|
||
|
||
// Определяем значения для board_id и author_id
|
||
var boardIDVal, authorIDVal interface{}
|
||
if boardID.Valid {
|
||
boardIDVal = int(boardID.Int64)
|
||
}
|
||
if authorID.Valid {
|
||
authorIDVal = int(authorID.Int64)
|
||
} else {
|
||
// Если author_id не был установлен, используем текущего пользователя
|
||
authorIDVal = userID
|
||
}
|
||
|
||
err = tx.QueryRow(`
|
||
INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, completed, deleted)
|
||
VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE)
|
||
RETURNING id
|
||
`, ownerID, boardIDVal, authorIDVal, name+" (копия)", priceVal, linkVal).Scan(&newWishlistID)
|
||
if err != nil {
|
||
log.Printf("Error creating wishlist copy: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist copy: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Сохраняем условия
|
||
if len(conditions) > 0 {
|
||
err = a.saveWishlistConditions(tx, newWishlistID, userID, conditions)
|
||
if err != nil {
|
||
log.Printf("Error saving wishlist conditions: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Копируем изображение, если есть
|
||
if imagePath.Valid && imagePath.String != "" {
|
||
// Получаем путь к оригинальному файлу
|
||
uploadsDir := getEnv("UPLOADS_DIR", "/app/uploads")
|
||
|
||
// Очищаем путь от /uploads/ в начале и query параметров
|
||
cleanPath := imagePath.String
|
||
cleanPath = strings.TrimPrefix(cleanPath, "/uploads/")
|
||
if idx := strings.Index(cleanPath, "?"); idx != -1 {
|
||
cleanPath = cleanPath[:idx]
|
||
}
|
||
|
||
originalPath := filepath.Join(uploadsDir, cleanPath)
|
||
|
||
log.Printf("Copying image: imagePath=%s, cleanPath=%s, originalPath=%s", imagePath.String, cleanPath, originalPath)
|
||
|
||
// Проверяем, существует ли файл
|
||
if _, statErr := os.Stat(originalPath); statErr == nil {
|
||
// Создаём директорию для нового желания
|
||
newImageDir := filepath.Join(uploadsDir, "wishlist", strconv.Itoa(userID))
|
||
if mkdirErr := os.MkdirAll(newImageDir, 0755); mkdirErr != nil {
|
||
log.Printf("Error creating image dir: %v", mkdirErr)
|
||
}
|
||
|
||
// Генерируем уникальное имя файла
|
||
ext := filepath.Ext(cleanPath)
|
||
randomBytes := make([]byte, 8)
|
||
rand.Read(randomBytes)
|
||
newFileName := fmt.Sprintf("%d_%s%s", newWishlistID, hex.EncodeToString(randomBytes), ext)
|
||
newImagePath := filepath.Join(newImageDir, newFileName)
|
||
|
||
log.Printf("New image path: %s", newImagePath)
|
||
|
||
// Копируем файл
|
||
srcFile, openErr := os.Open(originalPath)
|
||
if openErr != nil {
|
||
log.Printf("Error opening source file: %v", openErr)
|
||
} else {
|
||
defer srcFile.Close()
|
||
dstFile, createErr := os.Create(newImagePath)
|
||
if createErr != nil {
|
||
log.Printf("Error creating dest file: %v", createErr)
|
||
} else {
|
||
defer dstFile.Close()
|
||
_, copyErr := io.Copy(dstFile, srcFile)
|
||
if copyErr != nil {
|
||
log.Printf("Error copying file: %v", copyErr)
|
||
} else {
|
||
// Обновляем путь к изображению в БД (с /uploads/ в начале для совместимости)
|
||
relativePath := "/uploads/" + filepath.Join("wishlist", strconv.Itoa(userID), newFileName)
|
||
log.Printf("Updating image_path in DB to: %s", relativePath)
|
||
_, updateErr := tx.Exec(`UPDATE wishlist_items SET image_path = $1 WHERE id = $2`, relativePath, newWishlistID)
|
||
if updateErr != nil {
|
||
log.Printf("Error updating image_path in DB: %v", updateErr)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
log.Printf("Original image file not found: %s, error: %v", originalPath, statErr)
|
||
}
|
||
} else {
|
||
log.Printf("No image to copy: imagePath.Valid=%v, imagePath.String=%s", imagePath.Valid, imagePath.String)
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
log.Printf("Error committing transaction: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Получаем созданное желание с условиями
|
||
items, err := a.getWishlistItemsWithConditions(userID, false)
|
||
if err != nil {
|
||
log.Printf("Error getting created wishlist item: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting created wishlist item: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
var createdItem *WishlistItem
|
||
for i := range items {
|
||
if items[i].ID == newWishlistID {
|
||
createdItem = &items[i]
|
||
break
|
||
}
|
||
}
|
||
|
||
if createdItem == nil {
|
||
sendErrorWithCORS(w, "Created item not found", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(createdItem)
|
||
}
|
||
|
||
// ============================================
|
||
// Wishlist Boards handlers
|
||
// ============================================
|
||
|
||
// generateInviteToken генерирует уникальный токен для приглашения
|
||
func generateInviteToken() string {
|
||
b := make([]byte, 32)
|
||
rand.Read(b)
|
||
return hex.EncodeToString(b)
|
||
}
|
||
|
||
// getBoardsHandler возвращает список досок пользователя (свои + присоединённые)
|
||
func (a *App) getBoardsHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
boards := []WishlistBoard{}
|
||
|
||
// Получаем свои доски + доски где пользователь участник
|
||
rows, err := a.DB.Query(`
|
||
SELECT DISTINCT
|
||
wb.id,
|
||
wb.owner_id,
|
||
COALESCE(u.name, u.email) as owner_name,
|
||
wb.name,
|
||
wb.invite_enabled,
|
||
wb.invite_token,
|
||
wb.created_at,
|
||
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count,
|
||
(wb.owner_id = $1) as is_owner
|
||
FROM wishlist_boards wb
|
||
JOIN users u ON wb.owner_id = u.id
|
||
LEFT JOIN wishlist_board_members wbm ON wb.id = wbm.board_id
|
||
WHERE wb.deleted = FALSE
|
||
AND (wb.owner_id = $1 OR wbm.user_id = $1)
|
||
ORDER BY is_owner DESC, wb.created_at DESC
|
||
`, userID)
|
||
if err != nil {
|
||
log.Printf("Error getting boards: %v", err)
|
||
sendErrorWithCORS(w, "Error getting boards", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||
|
||
for rows.Next() {
|
||
var board WishlistBoard
|
||
var inviteToken sql.NullString
|
||
err := rows.Scan(
|
||
&board.ID,
|
||
&board.OwnerID,
|
||
&board.OwnerName,
|
||
&board.Name,
|
||
&board.InviteEnabled,
|
||
&inviteToken,
|
||
&board.CreatedAt,
|
||
&board.MemberCount,
|
||
&board.IsOwner,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning board: %v", err)
|
||
continue
|
||
}
|
||
|
||
// Invite token и URL только для владельца
|
||
if board.IsOwner && inviteToken.Valid {
|
||
board.InviteToken = &inviteToken.String
|
||
if baseURL != "" {
|
||
url := baseURL + "/invite/" + inviteToken.String
|
||
board.InviteURL = &url
|
||
}
|
||
}
|
||
|
||
boards = append(boards, board)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(boards)
|
||
}
|
||
|
||
// createBoardHandler создаёт новую доску
|
||
func (a *App) createBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
var req BoardRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
sendErrorWithCORS(w, "Name is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
var boardID int
|
||
err := a.DB.QueryRow(`
|
||
INSERT INTO wishlist_boards (owner_id, name)
|
||
VALUES ($1, $2)
|
||
RETURNING id
|
||
`, userID, strings.TrimSpace(req.Name)).Scan(&boardID)
|
||
|
||
if err != nil {
|
||
log.Printf("Error creating board: %v", err)
|
||
sendErrorWithCORS(w, "Error creating board", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Возвращаем созданную доску
|
||
board := WishlistBoard{
|
||
ID: boardID,
|
||
OwnerID: userID,
|
||
Name: strings.TrimSpace(req.Name),
|
||
InviteEnabled: false,
|
||
MemberCount: 0,
|
||
IsOwner: true,
|
||
CreatedAt: time.Now(),
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
json.NewEncoder(w).Encode(board)
|
||
}
|
||
|
||
// getBoardHandler возвращает детали доски
|
||
func (a *App) getBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
boardID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
var board WishlistBoard
|
||
var inviteToken sql.NullString
|
||
|
||
err = a.DB.QueryRow(`
|
||
SELECT
|
||
wb.id,
|
||
wb.owner_id,
|
||
COALESCE(u.name, u.email) as owner_name,
|
||
wb.name,
|
||
wb.invite_enabled,
|
||
wb.invite_token,
|
||
wb.created_at,
|
||
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count
|
||
FROM wishlist_boards wb
|
||
JOIN users u ON wb.owner_id = u.id
|
||
WHERE wb.id = $1 AND wb.deleted = FALSE
|
||
`, boardID).Scan(
|
||
&board.ID,
|
||
&board.OwnerID,
|
||
&board.OwnerName,
|
||
&board.Name,
|
||
&board.InviteEnabled,
|
||
&inviteToken,
|
||
&board.CreatedAt,
|
||
&board.MemberCount,
|
||
)
|
||
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error getting board: %v", err)
|
||
sendErrorWithCORS(w, "Error getting board", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
board.IsOwner = board.OwnerID == userID
|
||
|
||
// Проверяем доступ (владелец или участник)
|
||
if !board.IsOwner {
|
||
var isMember bool
|
||
a.DB.QueryRow(`
|
||
SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)
|
||
`, boardID, userID).Scan(&isMember)
|
||
|
||
if !isMember {
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Invite token и URL только для владельца
|
||
if board.IsOwner && inviteToken.Valid {
|
||
board.InviteToken = &inviteToken.String
|
||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||
if baseURL != "" {
|
||
url := baseURL + "/invite/" + inviteToken.String
|
||
board.InviteURL = &url
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(board)
|
||
}
|
||
|
||
// updateBoardHandler обновляет доску (только владелец)
|
||
func (a *App) updateBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
boardID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем что пользователь - владелец
|
||
var ownerID int
|
||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if ownerID != userID {
|
||
sendErrorWithCORS(w, "Only owner can update board", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
var req BoardRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Обновляем поля
|
||
if strings.TrimSpace(req.Name) != "" {
|
||
_, err = a.DB.Exec(`UPDATE wishlist_boards SET name = $1, updated_at = NOW() WHERE id = $2`,
|
||
strings.TrimSpace(req.Name), boardID)
|
||
if err != nil {
|
||
log.Printf("Error updating board name: %v", err)
|
||
}
|
||
}
|
||
|
||
if req.InviteEnabled != nil {
|
||
// Если включаем приглашения и нет токена - генерируем
|
||
if *req.InviteEnabled {
|
||
var currentToken sql.NullString
|
||
a.DB.QueryRow(`SELECT invite_token FROM wishlist_boards WHERE id = $1`, boardID).Scan(¤tToken)
|
||
|
||
if !currentToken.Valid || currentToken.String == "" {
|
||
token := generateInviteToken()
|
||
_, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_enabled = TRUE, invite_token = $1, updated_at = NOW() WHERE id = $2`,
|
||
token, boardID)
|
||
} else {
|
||
_, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_enabled = TRUE, updated_at = NOW() WHERE id = $1`, boardID)
|
||
}
|
||
} else {
|
||
_, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_enabled = FALSE, updated_at = NOW() WHERE id = $1`, boardID)
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error updating board invite_enabled: %v", err)
|
||
}
|
||
}
|
||
|
||
// Возвращаем обновлённую доску
|
||
var board WishlistBoard
|
||
var inviteToken sql.NullString
|
||
|
||
a.DB.QueryRow(`
|
||
SELECT
|
||
wb.id, wb.owner_id, COALESCE(u.name, u.email), wb.name, wb.invite_enabled, wb.invite_token, wb.created_at,
|
||
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id)
|
||
FROM wishlist_boards wb
|
||
JOIN users u ON wb.owner_id = u.id
|
||
WHERE wb.id = $1
|
||
`, boardID).Scan(&board.ID, &board.OwnerID, &board.OwnerName, &board.Name, &board.InviteEnabled, &inviteToken, &board.CreatedAt, &board.MemberCount)
|
||
|
||
board.IsOwner = true
|
||
if inviteToken.Valid {
|
||
board.InviteToken = &inviteToken.String
|
||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||
if baseURL != "" {
|
||
url := baseURL + "/invite/" + inviteToken.String
|
||
board.InviteURL = &url
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(board)
|
||
}
|
||
|
||
// deleteBoardHandler удаляет доску (только владелец)
|
||
func (a *App) deleteBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
boardID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем что пользователь - владелец
|
||
var ownerID int
|
||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if ownerID != userID {
|
||
sendErrorWithCORS(w, "Only owner can delete board", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Soft delete доски и всех её желаний
|
||
_, err = a.DB.Exec(`UPDATE wishlist_boards SET deleted = TRUE, updated_at = NOW() WHERE id = $1`, boardID)
|
||
if err != nil {
|
||
log.Printf("Error deleting board: %v", err)
|
||
sendErrorWithCORS(w, "Error deleting board", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Soft delete всех желаний на доске
|
||
_, err = a.DB.Exec(`UPDATE wishlist_items SET deleted = TRUE, updated_at = NOW() WHERE board_id = $1`, boardID)
|
||
if err != nil {
|
||
log.Printf("Error deleting board items: %v", err)
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// regenerateBoardInviteHandler перегенерирует invite token
|
||
func (a *App) regenerateBoardInviteHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
boardID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем что пользователь - владелец
|
||
var ownerID int
|
||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if ownerID != userID {
|
||
sendErrorWithCORS(w, "Only owner can regenerate invite", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
token := generateInviteToken()
|
||
_, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_token = $1, invite_enabled = TRUE, updated_at = NOW() WHERE id = $2`,
|
||
token, boardID)
|
||
if err != nil {
|
||
log.Printf("Error regenerating invite token: %v", err)
|
||
sendErrorWithCORS(w, "Error regenerating invite", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||
inviteURL := ""
|
||
if baseURL != "" {
|
||
inviteURL = baseURL + "/invite/" + token
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(map[string]string{
|
||
"invite_token": token,
|
||
"invite_url": inviteURL,
|
||
})
|
||
}
|
||
|
||
// getBoardMembersHandler возвращает список участников доски
|
||
func (a *App) getBoardMembersHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
boardID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем что пользователь - владелец
|
||
var ownerID int
|
||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if ownerID != userID {
|
||
sendErrorWithCORS(w, "Only owner can view members", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
members := []BoardMember{}
|
||
rows, err := a.DB.Query(`
|
||
SELECT wbm.id, wbm.user_id, COALESCE(u.name, '') as name, u.email, wbm.joined_at
|
||
FROM wishlist_board_members wbm
|
||
JOIN users u ON wbm.user_id = u.id
|
||
WHERE wbm.board_id = $1
|
||
ORDER BY wbm.joined_at DESC
|
||
`, boardID)
|
||
if err != nil {
|
||
log.Printf("Error getting members: %v", err)
|
||
sendErrorWithCORS(w, "Error getting members", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
for rows.Next() {
|
||
var member BoardMember
|
||
err := rows.Scan(&member.ID, &member.UserID, &member.Name, &member.Email, &member.JoinedAt)
|
||
if err != nil {
|
||
log.Printf("Error scanning member: %v", err)
|
||
continue
|
||
}
|
||
members = append(members, member)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(members)
|
||
}
|
||
|
||
// removeBoardMemberHandler удаляет участника из доски
|
||
func (a *App) removeBoardMemberHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
boardID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
memberUserID, err := strconv.Atoi(vars["userId"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid user ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем что пользователь - владелец
|
||
var ownerID int
|
||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if ownerID != userID {
|
||
sendErrorWithCORS(w, "Only owner can remove members", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
_, err = a.DB.Exec(`DELETE FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2`, boardID, memberUserID)
|
||
if err != nil {
|
||
log.Printf("Error removing member: %v", err)
|
||
sendErrorWithCORS(w, "Error removing member", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// leaveBoardHandler позволяет участнику выйти из доски
|
||
func (a *App) leaveBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
boardID, err := strconv.Atoi(vars["id"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем что пользователь НЕ владелец
|
||
var ownerID int
|
||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
if ownerID == userID {
|
||
sendErrorWithCORS(w, "Owner cannot leave board, delete it instead", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
_, err = a.DB.Exec(`DELETE FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID)
|
||
if err != nil {
|
||
log.Printf("Error leaving board: %v", err)
|
||
sendErrorWithCORS(w, "Error leaving board", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// getBoardInviteInfoHandler возвращает информацию о доске по invite token
|
||
func (a *App) getBoardInviteInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
vars := mux.Vars(r)
|
||
token := vars["token"]
|
||
|
||
var info BoardInviteInfo
|
||
var ownerName string
|
||
err := a.DB.QueryRow(`
|
||
SELECT
|
||
wb.id,
|
||
wb.name,
|
||
COALESCE(u.name, u.email) as owner_name,
|
||
(SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count
|
||
FROM wishlist_boards wb
|
||
JOIN users u ON wb.owner_id = u.id
|
||
WHERE wb.invite_token = $1 AND wb.invite_enabled = TRUE AND wb.deleted = FALSE
|
||
`, token).Scan(&info.BoardID, &info.Name, &ownerName, &info.MemberCount)
|
||
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error getting invite info: %v", err)
|
||
sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
info.OwnerName = ownerName
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(info)
|
||
}
|
||
|
||
// joinBoardHandler присоединяет пользователя к доске по invite token
|
||
func (a *App) joinBoardHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
token := vars["token"]
|
||
|
||
// Получаем доску по токену
|
||
var boardID, ownerID int
|
||
var boardName, ownerName string
|
||
err := a.DB.QueryRow(`
|
||
SELECT wb.id, wb.owner_id, wb.name, COALESCE(u.name, u.email)
|
||
FROM wishlist_boards wb
|
||
JOIN users u ON wb.owner_id = u.id
|
||
WHERE wb.invite_token = $1 AND wb.invite_enabled = TRUE AND wb.deleted = FALSE
|
||
`, token).Scan(&boardID, &ownerID, &boardName, &ownerName)
|
||
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound)
|
||
return
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error getting board by token: %v", err)
|
||
sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Проверяем что пользователь не владелец
|
||
if ownerID == userID {
|
||
sendErrorWithCORS(w, "You are the owner of this board", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем что пользователь ещё не участник
|
||
var exists bool
|
||
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`,
|
||
boardID, userID).Scan(&exists)
|
||
if exists {
|
||
sendErrorWithCORS(w, "You are already a member of this board", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Добавляем пользователя как участника
|
||
_, err = a.DB.Exec(`INSERT INTO wishlist_board_members (board_id, user_id) VALUES ($1, $2)`, boardID, userID)
|
||
if err != nil {
|
||
log.Printf("Error joining board: %v", err)
|
||
sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Получаем количество участников
|
||
var memberCount int
|
||
a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_board_members WHERE board_id = $1`, boardID).Scan(&memberCount)
|
||
|
||
board := WishlistBoard{
|
||
ID: boardID,
|
||
OwnerID: ownerID,
|
||
OwnerName: ownerName,
|
||
Name: boardName,
|
||
InviteEnabled: true,
|
||
MemberCount: memberCount,
|
||
IsOwner: false,
|
||
}
|
||
|
||
response := JoinBoardResponse{
|
||
Board: board,
|
||
Message: "Вы успешно присоединились к доске!",
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
// getBoardItemsHandler возвращает желания на доске
|
||
func (a *App) getBoardItemsHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
boardID, err := strconv.Atoi(vars["boardId"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем доступ к доске (владелец или участник)
|
||
var ownerID int
|
||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
hasAccess := ownerID == userID
|
||
if !hasAccess {
|
||
var isMember bool
|
||
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`,
|
||
boardID, userID).Scan(&isMember)
|
||
hasAccess = isMember
|
||
}
|
||
|
||
if !hasAccess {
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Получаем желания на доске (используем существующую логику, но фильтруем по board_id)
|
||
items, err := a.getWishlistItemsByBoard(boardID, userID)
|
||
if err != nil {
|
||
log.Printf("Error getting board items: %v", err)
|
||
sendErrorWithCORS(w, "Error getting items", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Разделяем на unlocked/locked
|
||
unlocked := []WishlistItem{}
|
||
locked := []WishlistItem{}
|
||
for _, item := range items {
|
||
if item.Unlocked {
|
||
unlocked = append(unlocked, item)
|
||
} else {
|
||
locked = append(locked, item)
|
||
}
|
||
}
|
||
|
||
// Сортируем разблокированные по цене от меньшего к большему
|
||
sort.Slice(unlocked, func(i, j int) bool {
|
||
priceI := 0.0
|
||
priceJ := 0.0
|
||
if unlocked[i].Price != nil {
|
||
priceI = *unlocked[i].Price
|
||
}
|
||
if unlocked[j].Price != nil {
|
||
priceJ = *unlocked[j].Price
|
||
}
|
||
if priceI == priceJ {
|
||
return unlocked[i].ID < unlocked[j].ID
|
||
}
|
||
return priceI < priceJ
|
||
})
|
||
|
||
// Разделяем заблокированные на группы (с задачами и без задач)
|
||
lockedWithoutTasks := []WishlistItem{}
|
||
lockedWithTasks := []WishlistItem{}
|
||
|
||
for _, item := range locked {
|
||
hasUncompletedTasks := false
|
||
for _, cond := range item.UnlockConditions {
|
||
if cond.Type == "task_completion" && (cond.TaskCompleted == nil || !*cond.TaskCompleted) {
|
||
hasUncompletedTasks = true
|
||
break
|
||
}
|
||
}
|
||
if hasUncompletedTasks {
|
||
lockedWithTasks = append(lockedWithTasks, item)
|
||
} else {
|
||
lockedWithoutTasks = append(lockedWithoutTasks, item)
|
||
}
|
||
}
|
||
|
||
// Сортируем каждую группу по времени разблокировки (от меньшего срока к большему)
|
||
sort.Slice(lockedWithoutTasks, func(i, j int) bool {
|
||
valueI := a.calculateLockedSortValue(lockedWithoutTasks[i], userID)
|
||
valueJ := a.calculateLockedSortValue(lockedWithoutTasks[j], userID)
|
||
if valueI == valueJ {
|
||
return lockedWithoutTasks[i].ID < lockedWithoutTasks[j].ID
|
||
}
|
||
return valueI < valueJ
|
||
})
|
||
|
||
sort.Slice(lockedWithTasks, func(i, j int) bool {
|
||
valueI := a.calculateLockedSortValue(lockedWithTasks[i], userID)
|
||
valueJ := a.calculateLockedSortValue(lockedWithTasks[j], userID)
|
||
if valueI == valueJ {
|
||
return lockedWithTasks[i].ID < lockedWithTasks[j].ID
|
||
}
|
||
return valueI < valueJ
|
||
})
|
||
|
||
// Объединяем: сначала без задач, потом с задачами
|
||
locked = append(lockedWithoutTasks, lockedWithTasks...)
|
||
|
||
// Считаем завершённые
|
||
var completedCount int
|
||
a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_items WHERE board_id = $1 AND completed = TRUE AND deleted = FALSE`,
|
||
boardID).Scan(&completedCount)
|
||
|
||
response := WishlistResponse{
|
||
Unlocked: unlocked,
|
||
Locked: locked,
|
||
CompletedCount: completedCount,
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(response)
|
||
}
|
||
|
||
// getBoardCompletedHandler возвращает завершённые желания на доске
|
||
func (a *App) getBoardCompletedHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
boardID, err := strconv.Atoi(vars["boardId"])
|
||
if err != nil {
|
||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем доступ к доске (владелец или участник)
|
||
var ownerID int
|
||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
hasAccess := ownerID == userID
|
||
if !hasAccess {
|
||
var isMember bool
|
||
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`,
|
||
boardID, userID).Scan(&isMember)
|
||
hasAccess = isMember
|
||
}
|
||
|
||
if !hasAccess {
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
// Получаем завершённые желания на доске (отдельный запрос, так как getWishlistItemsByBoard исключает завершённые)
|
||
query := `
|
||
SELECT
|
||
wi.id,
|
||
wi.name,
|
||
wi.price,
|
||
wi.image_path,
|
||
wi.link,
|
||
wi.completed,
|
||
wi.project_id AS item_project_id,
|
||
wp.name AS item_project_name,
|
||
wc.id AS condition_id,
|
||
wc.display_order,
|
||
wc.task_condition_id,
|
||
wc.score_condition_id,
|
||
wc.user_id,
|
||
tc.task_id,
|
||
t.name AS task_name,
|
||
sc.project_id,
|
||
p.name AS project_name,
|
||
sc.required_points,
|
||
sc.start_date,
|
||
COALESCE(u.name, u.email) AS user_name
|
||
FROM wishlist_items wi
|
||
LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE
|
||
LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id
|
||
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||
LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE
|
||
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||
LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE
|
||
LEFT JOIN users u ON wc.user_id = u.id
|
||
WHERE wi.board_id = $1
|
||
AND wi.deleted = FALSE
|
||
AND wi.completed = TRUE
|
||
ORDER BY wi.id, wc.display_order, wc.id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, boardID)
|
||
if err != nil {
|
||
log.Printf("Error executing query for board completed items (boardID=%d): %v", boardID, err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting completed items: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer rows.Close()
|
||
|
||
itemsMap := make(map[int]*WishlistItem)
|
||
|
||
for rows.Next() {
|
||
var itemID int
|
||
var name string
|
||
var price sql.NullFloat64
|
||
var imagePath sql.NullString
|
||
var link sql.NullString
|
||
var completed bool
|
||
var itemProjectID sql.NullInt64
|
||
var itemProjectName sql.NullString
|
||
var conditionID sql.NullInt64
|
||
var displayOrder sql.NullInt64
|
||
var taskConditionID sql.NullInt64
|
||
var scoreConditionID sql.NullInt64
|
||
var userIDCond sql.NullInt64
|
||
var taskID sql.NullInt64
|
||
var taskName sql.NullString
|
||
var projectID sql.NullInt64
|
||
var projectName sql.NullString
|
||
var requiredPoints sql.NullFloat64
|
||
var startDate sql.NullTime
|
||
var userName sql.NullString
|
||
|
||
err := rows.Scan(
|
||
&itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName,
|
||
&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
|
||
}
|
||
// Для завершённых желаний не устанавливаем project_id и project_name
|
||
// Они отображаются отдельно без группировки по проектам
|
||
itemsMap[itemID] = item
|
||
}
|
||
|
||
if conditionID.Valid {
|
||
// Определяем владельца условия
|
||
conditionOwnerID := userID
|
||
if userIDCond.Valid {
|
||
conditionOwnerID = int(userIDCond.Int64)
|
||
}
|
||
|
||
// Если это условие по задаче, проверяем существует ли задача
|
||
if taskConditionID.Valid && taskID.Valid {
|
||
// Проверяем, существует ли задача (не удалена)
|
||
var taskExists bool
|
||
err := a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)`, taskID.Int64, conditionOwnerID).Scan(&taskExists)
|
||
if err != nil || !taskExists {
|
||
// Задача удалена - не добавляем условие в список, но при проверке блокировки оно считается выполненным
|
||
continue
|
||
}
|
||
}
|
||
|
||
condition := UnlockConditionDisplay{
|
||
ID: int(conditionID.Int64),
|
||
DisplayOrder: int(displayOrder.Int64),
|
||
}
|
||
|
||
if taskConditionID.Valid {
|
||
condition.Type = "task_completion"
|
||
if taskID.Valid {
|
||
taskIDVal := int(taskID.Int64)
|
||
condition.TaskID = &taskIDVal
|
||
if taskName.Valid {
|
||
condition.TaskName = &taskName.String
|
||
}
|
||
}
|
||
} else if scoreConditionID.Valid {
|
||
condition.Type = "project_points"
|
||
if projectID.Valid {
|
||
projectIDVal := int(projectID.Int64)
|
||
condition.ProjectID = &projectIDVal
|
||
if projectName.Valid {
|
||
condition.ProjectName = &projectName.String
|
||
}
|
||
if requiredPoints.Valid {
|
||
condition.RequiredPoints = &requiredPoints.Float64
|
||
}
|
||
if startDate.Valid {
|
||
dateStr := startDate.Time.Format("2006-01-02")
|
||
condition.StartDate = &dateStr
|
||
}
|
||
}
|
||
}
|
||
|
||
if userIDCond.Valid {
|
||
userIDVal := int(userIDCond.Int64)
|
||
condition.UserID = &userIDVal
|
||
if userName.Valid {
|
||
condition.UserName = &userName.String
|
||
}
|
||
}
|
||
|
||
item.UnlockConditions = append(item.UnlockConditions, condition)
|
||
}
|
||
}
|
||
|
||
if err := rows.Err(); err != nil {
|
||
log.Printf("Error iterating rows for board completed items (boardID=%d): %v", boardID, err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error getting completed items: %v", err), http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Преобразуем map в slice
|
||
completed := make([]WishlistItem, 0, len(itemsMap))
|
||
for _, item := range itemsMap {
|
||
completed = append(completed, *item)
|
||
}
|
||
|
||
// Сортируем по цене (дорогие → дешёвые)
|
||
sort.Slice(completed, func(i, j int) bool {
|
||
priceI := 0.0
|
||
priceJ := 0.0
|
||
if completed[i].Price != nil {
|
||
priceI = *completed[i].Price
|
||
}
|
||
if completed[j].Price != nil {
|
||
priceJ = *completed[j].Price
|
||
}
|
||
return priceI > priceJ
|
||
})
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(completed)
|
||
}
|
||
|
||
// calculateUnlockedSortValue считает сумму баллов, которые были нужны для разблокировки
|
||
// Задача считается как 1 балл, project_points как required_points
|
||
func calculateUnlockedSortValue(item WishlistItem) float64 {
|
||
var totalRequired float64 = 0.0
|
||
for _, condition := range item.UnlockConditions {
|
||
if condition.Type == "task_completion" {
|
||
totalRequired += 1.0
|
||
} else if condition.Type == "project_points" {
|
||
if condition.RequiredPoints != nil {
|
||
totalRequired += *condition.RequiredPoints
|
||
}
|
||
}
|
||
}
|
||
return totalRequired
|
||
}
|
||
|
||
// calculateLockedSortValue считает сумму оставшихся баллов для разблокировки
|
||
// Задача считается как 1 балл (если не выполнена), project_points как remaining баллы
|
||
func (a *App) calculateLockedSortValue(item WishlistItem, userID int) float64 {
|
||
// Если нет условий, возвращаем большое значение (отсутствие условий = все выполнены)
|
||
if len(item.UnlockConditions) == 0 {
|
||
return 999999.0
|
||
}
|
||
|
||
maxWeeks := 0.0
|
||
hasProjectConditions := false
|
||
allCompleted := true
|
||
|
||
for _, condition := range item.UnlockConditions {
|
||
if condition.Type == "project_points" {
|
||
hasProjectConditions = true
|
||
if condition.RequiredPoints != nil {
|
||
var startDate sql.NullTime
|
||
if condition.StartDate != nil {
|
||
date, err := time.Parse("2006-01-02", *condition.StartDate)
|
||
if err == nil {
|
||
startDate = sql.NullTime{Time: date, Valid: true}
|
||
}
|
||
}
|
||
|
||
// ВАЖНО: Используем владельца условия из condition.UserID
|
||
// Если condition.UserID есть - это владелец условия
|
||
// Если нет - получаем владельца желания из БД (для старых условий)
|
||
// НЕ используем текущего пользователя (userID), так как условие может принадлежать другому пользователю
|
||
conditionOwnerID := 0
|
||
if condition.UserID != nil {
|
||
conditionOwnerID = *condition.UserID
|
||
} else {
|
||
// Если нет владельца условия, получаем владельца желания из БД
|
||
var itemOwnerID int
|
||
err := a.DB.QueryRow(`SELECT user_id FROM wishlist_items WHERE id = $1`, item.ID).Scan(&itemOwnerID)
|
||
if err != nil {
|
||
log.Printf("Error getting wishlist item owner for item %d: %v", item.ID, err)
|
||
continue // Пропускаем условие, если не можем получить владельца
|
||
}
|
||
conditionOwnerID = itemOwnerID
|
||
}
|
||
|
||
// Получаем projectID из условия
|
||
if condition.ProjectID != nil {
|
||
weeks := a.calculateProjectUnlockWeeks(
|
||
*condition.ProjectID,
|
||
*condition.RequiredPoints,
|
||
startDate,
|
||
conditionOwnerID, // Владелец условия, а не текущий пользователь
|
||
)
|
||
// weeks > 0 && < 99999 означает, что условие еще не выполнено и расчет успешен
|
||
// weeks == 0 означает условие выполнено
|
||
// weeks == 99999 означает медиана отсутствует (нельзя рассчитать) или ошибка расчета
|
||
if weeks == 0 {
|
||
// Условие выполнено - считаем как 0 недель
|
||
// Не обновляем maxWeeks, так как 0 < любого положительного значения
|
||
} else if weeks > 0 && weeks < 99999 {
|
||
// Условие не выполнено - учитываем в maxWeeks
|
||
allCompleted = false
|
||
if weeks > maxWeeks {
|
||
maxWeeks = weeks
|
||
}
|
||
} else {
|
||
// weeks == 99999 - нельзя рассчитать, считаем как невыполненное
|
||
allCompleted = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Если были условия по проектам и все выполнены, возвращаем 0 (закрытые испытания = 0 недель)
|
||
if hasProjectConditions && allCompleted {
|
||
return 0.0
|
||
}
|
||
|
||
// Если не было условий по проектам (только задачи или нет условий)
|
||
if !hasProjectConditions {
|
||
return 999999.0
|
||
}
|
||
|
||
return maxWeeks
|
||
}
|
||
|
||
// getWishlistItemsByBoard загружает желания конкретной доски
|
||
|
||
func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, error) {
|
||
query := `
|
||
SELECT
|
||
wi.id,
|
||
wi.name,
|
||
wi.price,
|
||
wi.image_path,
|
||
wi.link,
|
||
wi.completed,
|
||
wi.project_id AS item_project_id,
|
||
wp.name AS item_project_name,
|
||
COALESCE(wi.author_id, wi.user_id) AS item_owner_id,
|
||
wc.id AS condition_id,
|
||
wc.display_order,
|
||
wc.task_condition_id,
|
||
wc.score_condition_id,
|
||
wc.user_id AS condition_user_id,
|
||
tc.task_id,
|
||
t.name AS task_name,
|
||
sc.project_id,
|
||
p.name AS project_name,
|
||
sc.required_points,
|
||
sc.start_date
|
||
FROM wishlist_items wi
|
||
LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE
|
||
LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id
|
||
LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id
|
||
LEFT JOIN tasks t ON tc.task_id = t.id AND t.deleted = FALSE
|
||
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
|
||
LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE
|
||
WHERE wi.board_id = $1
|
||
AND wi.deleted = FALSE
|
||
AND wi.completed = FALSE
|
||
ORDER BY wi.id, wc.display_order, wc.id
|
||
`
|
||
|
||
rows, err := a.DB.Query(query, boardID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
|
||
itemsMap := make(map[int]*WishlistItem)
|
||
|
||
for rows.Next() {
|
||
var itemID int
|
||
var name string
|
||
var price sql.NullFloat64
|
||
var imagePath sql.NullString
|
||
var link sql.NullString
|
||
var completed bool
|
||
var itemProjectID sql.NullInt64
|
||
var itemProjectName sql.NullString
|
||
var itemOwnerID sql.NullInt64
|
||
var conditionID sql.NullInt64
|
||
var displayOrder sql.NullInt64
|
||
var taskConditionID sql.NullInt64
|
||
var scoreConditionID sql.NullInt64
|
||
var conditionUserID sql.NullInt64
|
||
var taskID sql.NullInt64
|
||
var taskName sql.NullString
|
||
var projectID sql.NullInt64
|
||
var projectName sql.NullString
|
||
var requiredPoints sql.NullFloat64
|
||
var startDate sql.NullTime
|
||
|
||
err := rows.Scan(
|
||
&itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName, &itemOwnerID,
|
||
&conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID,
|
||
&taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate,
|
||
)
|
||
if err != nil {
|
||
log.Printf("Error scanning wishlist item: %v", err)
|
||
continue
|
||
}
|
||
|
||
item, exists := itemsMap[itemID]
|
||
if !exists {
|
||
item = &WishlistItem{
|
||
ID: itemID,
|
||
Name: name,
|
||
Completed: completed,
|
||
UnlockConditions: []UnlockConditionDisplay{},
|
||
}
|
||
if price.Valid {
|
||
item.Price = &price.Float64
|
||
}
|
||
if imagePath.Valid && imagePath.String != "" {
|
||
url := imagePath.String
|
||
if !strings.HasPrefix(url, "http") {
|
||
url = url + "?t=" + strconv.FormatInt(time.Now().Unix(), 10)
|
||
}
|
||
item.ImageURL = &url
|
||
}
|
||
if link.Valid {
|
||
item.Link = &link.String
|
||
}
|
||
if itemProjectID.Valid {
|
||
projectIDVal := int(itemProjectID.Int64)
|
||
item.ProjectID = &projectIDVal
|
||
}
|
||
if itemProjectName.Valid {
|
||
projectNameVal := itemProjectName.String
|
||
item.ProjectName = &projectNameVal
|
||
}
|
||
itemsMap[itemID] = item
|
||
}
|
||
|
||
if conditionID.Valid {
|
||
// Используем user_id из условия, если он есть, иначе используем владельца желания
|
||
if !itemOwnerID.Valid {
|
||
log.Printf("Warning: item_owner_id is NULL for wishlist item %d, skipping condition", itemID)
|
||
continue
|
||
}
|
||
conditionOwnerID := int(itemOwnerID.Int64)
|
||
if conditionUserID.Valid {
|
||
conditionOwnerID = int(conditionUserID.Int64)
|
||
}
|
||
|
||
// Если это условие по задаче, проверяем существует ли задача
|
||
if taskConditionID.Valid && taskID.Valid {
|
||
// Проверяем, существует ли задача (не удалена)
|
||
var taskExists bool
|
||
err := a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE)`, taskID.Int64, conditionOwnerID).Scan(&taskExists)
|
||
if err != nil || !taskExists {
|
||
// Задача удалена - не добавляем условие в список, но при проверке блокировки оно считается выполненным
|
||
continue
|
||
}
|
||
}
|
||
|
||
condition := UnlockConditionDisplay{
|
||
ID: int(conditionID.Int64),
|
||
DisplayOrder: int(displayOrder.Int64),
|
||
}
|
||
|
||
if conditionUserID.Valid {
|
||
conditionOwnerIDVal := int(conditionUserID.Int64)
|
||
condition.UserID = &conditionOwnerIDVal
|
||
} else {
|
||
itemOwnerIDVal := int(itemOwnerID.Int64)
|
||
condition.UserID = &itemOwnerIDVal
|
||
}
|
||
|
||
if taskConditionID.Valid {
|
||
condition.Type = "task_completion"
|
||
if taskName.Valid {
|
||
condition.TaskName = &taskName.String
|
||
}
|
||
// Проверяем выполнена ли задача для владельца условия
|
||
if taskID.Valid {
|
||
taskIDVal := int(taskID.Int64)
|
||
condition.TaskID = &taskIDVal
|
||
var taskCompleted int
|
||
err := a.DB.QueryRow(`SELECT completed FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE`, taskID.Int64, conditionOwnerID).Scan(&taskCompleted)
|
||
if err == nil {
|
||
isCompleted := taskCompleted > 0
|
||
condition.TaskCompleted = &isCompleted
|
||
}
|
||
}
|
||
} else if scoreConditionID.Valid {
|
||
condition.Type = "project_points"
|
||
if projectName.Valid {
|
||
condition.ProjectName = &projectName.String
|
||
}
|
||
if projectID.Valid {
|
||
projectIDVal := int(projectID.Int64)
|
||
condition.ProjectID = &projectIDVal
|
||
// Считаем текущие баллы для владельца условия
|
||
points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID)
|
||
condition.CurrentPoints = &points
|
||
}
|
||
if requiredPoints.Valid {
|
||
condition.RequiredPoints = &requiredPoints.Float64
|
||
}
|
||
if startDate.Valid {
|
||
dateStr := startDate.Time.Format("2006-01-02")
|
||
condition.StartDate = &dateStr
|
||
}
|
||
// Рассчитываем и форматируем срок разблокировки
|
||
if condition.ProjectID != nil && condition.RequiredPoints != nil {
|
||
weeks := a.calculateProjectUnlockWeeks(
|
||
*condition.ProjectID,
|
||
*condition.RequiredPoints,
|
||
startDate,
|
||
conditionOwnerID,
|
||
)
|
||
weeksText := formatWeeksText(weeks)
|
||
condition.WeeksText = &weeksText
|
||
}
|
||
}
|
||
|
||
item.UnlockConditions = append(item.UnlockConditions, condition)
|
||
}
|
||
}
|
||
|
||
// Преобразуем map в slice и определяем unlocked
|
||
items := make([]WishlistItem, 0, len(itemsMap))
|
||
for _, item := range itemsMap {
|
||
// Сортируем условия в нужном порядке
|
||
a.sortUnlockConditions(item.UnlockConditions, userID)
|
||
|
||
// Проверяем все условия
|
||
item.Unlocked = true
|
||
if len(item.UnlockConditions) > 0 {
|
||
for _, cond := range item.UnlockConditions {
|
||
if cond.Type == "task_completion" {
|
||
if cond.TaskCompleted == nil || !*cond.TaskCompleted {
|
||
item.Unlocked = false
|
||
break
|
||
}
|
||
} else if cond.Type == "project_points" {
|
||
if cond.CurrentPoints == nil || cond.RequiredPoints == nil || *cond.CurrentPoints < *cond.RequiredPoints {
|
||
item.Unlocked = false
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Определяем первое заблокированное условие и количество остальных
|
||
if !item.Unlocked && !item.Completed {
|
||
lockedCount := 0
|
||
var firstLocked *UnlockConditionDisplay
|
||
for i := range item.UnlockConditions {
|
||
condition := &item.UnlockConditions[i]
|
||
if isConditionLocked(*condition) {
|
||
lockedCount++
|
||
if firstLocked == nil {
|
||
firstLocked = condition
|
||
}
|
||
}
|
||
}
|
||
if firstLocked != nil {
|
||
item.FirstLockedCondition = firstLocked
|
||
item.MoreLockedConditions = lockedCount - 1
|
||
item.LockedConditionsCount = lockedCount
|
||
}
|
||
}
|
||
|
||
// Загружаем связанную задачу текущего пользователя, если есть
|
||
var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
|
||
var linkedTaskName sql.NullString
|
||
var linkedTaskNextShowAt sql.NullTime
|
||
linkedTaskErr := a.DB.QueryRow(`
|
||
SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id
|
||
FROM tasks t
|
||
WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE
|
||
LIMIT 1
|
||
`, item.ID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID)
|
||
|
||
if linkedTaskErr == nil && linkedTaskID.Valid {
|
||
linkedTask := &LinkedTask{
|
||
ID: int(linkedTaskID.Int64),
|
||
Name: linkedTaskName.String,
|
||
Completed: int(linkedTaskCompleted.Int64),
|
||
}
|
||
if linkedTaskNextShowAt.Valid {
|
||
nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339)
|
||
linkedTask.NextShowAt = &nextShowAtStr
|
||
}
|
||
if linkedTaskUserID.Valid {
|
||
userIDVal := int(linkedTaskUserID.Int64)
|
||
linkedTask.UserID = &userIDVal
|
||
}
|
||
item.LinkedTask = linkedTask
|
||
} else if linkedTaskErr != sql.ErrNoRows {
|
||
log.Printf("Error loading linked task for wishlist %d: %v", item.ID, linkedTaskErr)
|
||
// Не возвращаем ошибку, просто не устанавливаем linked_task
|
||
}
|
||
|
||
// Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей)
|
||
// Исключаем linked_task из подсчета, если она есть
|
||
// Учитываем только не закрытые задачи (completed = 0)
|
||
var tasksCount int
|
||
if linkedTaskID.Valid {
|
||
// Если есть linked_task, исключаем её из подсчета
|
||
err = a.DB.QueryRow(`
|
||
SELECT COUNT(*)
|
||
FROM tasks t
|
||
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2
|
||
`, item.ID, linkedTaskID.Int64).Scan(&tasksCount)
|
||
} else {
|
||
// Если нет linked_task, считаем все не закрытые задачи
|
||
err = a.DB.QueryRow(`
|
||
SELECT COUNT(*)
|
||
FROM tasks t
|
||
WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0
|
||
`, item.ID).Scan(&tasksCount)
|
||
}
|
||
if err != nil {
|
||
log.Printf("Error counting tasks for wishlist %d: %v", item.ID, err)
|
||
tasksCount = 0
|
||
}
|
||
item.TasksCount = tasksCount
|
||
|
||
items = append(items, *item)
|
||
}
|
||
|
||
return items, nil
|
||
}
|
||
|
||
// createBoardItemHandler создаёт желание на доске
|
||
func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
vars := mux.Vars(r)
|
||
boardID, err := strconv.Atoi(vars["boardId"])
|
||
if err != nil {
|
||
log.Printf("createBoardItemHandler: Error parsing boardId from URL: %v, vars['boardId']='%s'", err, vars["boardId"])
|
||
sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Проверяем доступ к доске
|
||
var ownerID int
|
||
err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID)
|
||
if err == sql.ErrNoRows {
|
||
sendErrorWithCORS(w, "Board not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
hasAccess := ownerID == userID
|
||
if !hasAccess {
|
||
var isMember bool
|
||
a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`,
|
||
boardID, userID).Scan(&isMember)
|
||
hasAccess = isMember
|
||
}
|
||
|
||
if !hasAccess {
|
||
sendErrorWithCORS(w, "Access denied", http.StatusForbidden)
|
||
return
|
||
}
|
||
|
||
var req WishlistRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("createBoardItemHandler: Error decoding wishlist request: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
log.Printf("createBoardItemHandler: decoded request - name='%s', price=%v, link='%s', conditions=%d",
|
||
req.Name, req.Price, req.Link, len(req.UnlockConditions))
|
||
|
||
if req.UnlockConditions == nil {
|
||
log.Printf("createBoardItemHandler: WARNING - UnlockConditions is nil, initializing empty slice")
|
||
req.UnlockConditions = []UnlockConditionRequest{}
|
||
}
|
||
|
||
if strings.TrimSpace(req.Name) == "" {
|
||
log.Printf("createBoardItemHandler: Name is required")
|
||
sendErrorWithCORS(w, "Name is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
tx, err := a.DB.Begin()
|
||
if err != nil {
|
||
log.Printf("Error starting transaction: %v", err)
|
||
sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
defer tx.Rollback()
|
||
|
||
var itemID int
|
||
err = tx.QueryRow(`
|
||
INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, project_id, completed, deleted)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, FALSE)
|
||
RETURNING id
|
||
`, ownerID, boardID, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID).Scan(&itemID)
|
||
|
||
if err != nil {
|
||
log.Printf("createBoardItemHandler: Error creating board item: %v", err)
|
||
sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
log.Printf("createBoardItemHandler: created wishlist item id=%d", itemID)
|
||
|
||
// Сохраняем условия с user_id текущего пользователя
|
||
if len(req.UnlockConditions) > 0 {
|
||
log.Printf("createBoardItemHandler: saving %d conditions", len(req.UnlockConditions))
|
||
err = a.saveWishlistConditionsWithUserID(tx, itemID, userID, req.UnlockConditions)
|
||
if err != nil {
|
||
log.Printf("createBoardItemHandler: Error saving wishlist conditions: %v", err)
|
||
sendErrorWithCORS(w, "Error saving conditions", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
log.Printf("createBoardItemHandler: conditions saved successfully")
|
||
} else {
|
||
log.Printf("createBoardItemHandler: no conditions to save")
|
||
}
|
||
|
||
if err := tx.Commit(); err != nil {
|
||
log.Printf("Error committing transaction: %v", err)
|
||
sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Возвращаем созданное желание
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusCreated)
|
||
json.NewEncoder(w).Encode(map[string]int{"id": itemID})
|
||
}
|
||
|
||
// saveWishlistConditionsWithUserID сохраняет условия с указанием user_id
|
||
func (a *App) saveWishlistConditionsWithUserID(tx *sql.Tx, wishlistItemID int, userID int, conditions []UnlockConditionRequest) error {
|
||
log.Printf("saveWishlistConditionsWithUserID: wishlistItemID=%d, userID=%d, conditions=%d",
|
||
wishlistItemID, userID, len(conditions))
|
||
|
||
for i, cond := range conditions {
|
||
displayOrder := i
|
||
if cond.DisplayOrder != nil {
|
||
displayOrder = *cond.DisplayOrder
|
||
}
|
||
|
||
log.Printf("saveWishlistConditionsWithUserID: processing condition %d - type='%s', taskID=%v, projectID=%v",
|
||
i, cond.Type, cond.TaskID, cond.ProjectID)
|
||
|
||
switch cond.Type {
|
||
case "task_completion":
|
||
if cond.TaskID == nil {
|
||
continue
|
||
}
|
||
// Создаём task_condition
|
||
var taskConditionID int
|
||
err := tx.QueryRow(`
|
||
INSERT INTO task_conditions (task_id)
|
||
VALUES ($1)
|
||
ON CONFLICT (task_id) DO UPDATE SET task_id = EXCLUDED.task_id
|
||
RETURNING id
|
||
`, *cond.TaskID).Scan(&taskConditionID)
|
||
if err != nil {
|
||
log.Printf("saveWishlistConditionsWithUserID: error creating task condition: %v", err)
|
||
return fmt.Errorf("error creating task condition: %w", err)
|
||
}
|
||
// Связываем с wishlist_item
|
||
_, err = tx.Exec(`
|
||
INSERT INTO wishlist_conditions (wishlist_item_id, user_id, task_condition_id, display_order)
|
||
VALUES ($1, $2, $3, $4)
|
||
`, wishlistItemID, userID, taskConditionID, displayOrder)
|
||
if err != nil {
|
||
log.Printf("saveWishlistConditionsWithUserID: error linking task condition: %v", err)
|
||
return fmt.Errorf("error linking task condition: %w", err)
|
||
}
|
||
|
||
case "project_points":
|
||
if cond.ProjectID == nil || cond.RequiredPoints == nil {
|
||
continue
|
||
}
|
||
// Создаём score_condition
|
||
var scoreConditionID int
|
||
var startDateVal interface{} = nil
|
||
if cond.StartDate != nil && *cond.StartDate != "" {
|
||
startDateVal = *cond.StartDate
|
||
}
|
||
err := tx.QueryRow(`
|
||
INSERT INTO score_conditions (project_id, required_points, start_date)
|
||
VALUES ($1, $2, $3)
|
||
ON CONFLICT (project_id, required_points, start_date) DO UPDATE SET required_points = EXCLUDED.required_points
|
||
RETURNING id
|
||
`, *cond.ProjectID, *cond.RequiredPoints, startDateVal).Scan(&scoreConditionID)
|
||
if err != nil {
|
||
log.Printf("saveWishlistConditionsWithUserID: error creating score condition: %v", err)
|
||
return fmt.Errorf("error creating score condition: %w", err)
|
||
}
|
||
// Связываем с wishlist_item
|
||
_, err = tx.Exec(`
|
||
INSERT INTO wishlist_conditions (wishlist_item_id, user_id, score_condition_id, display_order)
|
||
VALUES ($1, $2, $3, $4)
|
||
`, wishlistItemID, userID, scoreConditionID, displayOrder)
|
||
if err != nil {
|
||
log.Printf("saveWishlistConditionsWithUserID: error linking score condition: %v", err)
|
||
return fmt.Errorf("error linking score condition: %w", err)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// LinkMetadataResponse структура ответа с метаданными ссылки
|
||
type LinkMetadataResponse struct {
|
||
Title string `json:"title,omitempty"`
|
||
Image string `json:"image,omitempty"`
|
||
Price *float64 `json:"price,omitempty"`
|
||
Description string `json:"description,omitempty"`
|
||
}
|
||
|
||
// extractMetadataViaHTTP извлекает метаданные через HTTP-запрос и парсинг HTML
|
||
// Это стандартный метод, используемый Telegram, Facebook и другими сервисами
|
||
func extractMetadataViaHTTP(targetURL string) (*LinkMetadataResponse, error) {
|
||
// Валидация URL
|
||
parsedURL, err := url.Parse(targetURL)
|
||
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
|
||
return nil, fmt.Errorf("invalid URL format: %s", targetURL)
|
||
}
|
||
|
||
// HTTP клиент с увеличенным таймаутом и поддержкой редиректов
|
||
transport := &http.Transport{
|
||
DisableKeepAlives: false,
|
||
MaxIdleConns: 10,
|
||
IdleConnTimeout: 90 * time.Second,
|
||
}
|
||
|
||
client := &http.Client{
|
||
Timeout: 30 * time.Second,
|
||
Transport: transport,
|
||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||
if len(via) >= 10 {
|
||
return fmt.Errorf("stopped after 10 redirects")
|
||
}
|
||
return nil
|
||
},
|
||
}
|
||
|
||
httpReq, err := http.NewRequest("GET", targetURL, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error creating request: %w", err)
|
||
}
|
||
|
||
// Устанавливаем заголовки, максимально имитирующие реальный браузер Chrome
|
||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||
httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||
httpReq.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7")
|
||
httpReq.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
|
||
httpReq.Header.Set("Connection", "keep-alive")
|
||
httpReq.Header.Set("Upgrade-Insecure-Requests", "1")
|
||
httpReq.Header.Set("Sec-Fetch-Dest", "document")
|
||
httpReq.Header.Set("Sec-Fetch-Mode", "navigate")
|
||
httpReq.Header.Set("Sec-Fetch-Site", "none")
|
||
httpReq.Header.Set("Sec-Fetch-User", "?1")
|
||
httpReq.Header.Set("Sec-Ch-Ua", `"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"`)
|
||
httpReq.Header.Set("Sec-Ch-Ua-Mobile", "?0")
|
||
httpReq.Header.Set("Sec-Ch-Ua-Platform", `"macOS"`)
|
||
httpReq.Header.Set("Cache-Control", "max-age=0")
|
||
httpReq.Header.Set("DNT", "1")
|
||
|
||
if parsedURL.Host != "" {
|
||
referer := fmt.Sprintf("%s://%s/", parsedURL.Scheme, parsedURL.Host)
|
||
httpReq.Header.Set("Referer", referer)
|
||
}
|
||
|
||
time.Sleep(100 * time.Millisecond)
|
||
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error fetching URL: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status)
|
||
}
|
||
|
||
limitedReader := io.LimitReader(resp.Body, 512*1024)
|
||
bodyBytes, err := io.ReadAll(limitedReader)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error reading response: %w", err)
|
||
}
|
||
|
||
if len(bodyBytes) >= 2 && bodyBytes[0] == 0x1f && bodyBytes[1] == 0x8b {
|
||
gzipReader, err := gzip.NewReader(bytes.NewReader(bodyBytes))
|
||
if err == nil {
|
||
defer gzipReader.Close()
|
||
decompressed, err := io.ReadAll(gzipReader)
|
||
if err == nil {
|
||
bodyBytes = decompressed
|
||
}
|
||
}
|
||
}
|
||
|
||
body := string(bodyBytes)
|
||
metadata := &LinkMetadataResponse{}
|
||
|
||
// Извлекаем Open Graph теги
|
||
ogTitleRe := regexp.MustCompile(`(?i)<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["']`)
|
||
|
||
if matches := ogTitleRe.FindStringSubmatch(body); len(matches) > 1 {
|
||
metadata.Title = strings.TrimSpace(matches[1])
|
||
} else if matches := ogTitleRe2.FindStringSubmatch(body); len(matches) > 1 {
|
||
metadata.Title = strings.TrimSpace(matches[1])
|
||
}
|
||
|
||
if matches := ogImageRe.FindStringSubmatch(body); len(matches) > 1 {
|
||
metadata.Image = strings.TrimSpace(matches[1])
|
||
} else if matches := ogImageRe2.FindStringSubmatch(body); len(matches) > 1 {
|
||
metadata.Image = strings.TrimSpace(matches[1])
|
||
}
|
||
|
||
if matches := ogDescRe.FindStringSubmatch(body); len(matches) > 1 {
|
||
metadata.Description = strings.TrimSpace(matches[1])
|
||
} else if matches := ogDescRe2.FindStringSubmatch(body); len(matches) > 1 {
|
||
metadata.Description = strings.TrimSpace(matches[1])
|
||
}
|
||
|
||
if metadata.Title == "" {
|
||
titleRe := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
|
||
if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 {
|
||
metadata.Title = strings.TrimSpace(matches[1])
|
||
if strings.Contains(strings.ToLower(metadata.Title), "робот") ||
|
||
strings.Contains(strings.ToLower(metadata.Title), "captcha") ||
|
||
strings.Contains(strings.ToLower(metadata.Title), "вы не робот") {
|
||
metadata.Title = ""
|
||
metadata.Image = ""
|
||
metadata.Description = ""
|
||
}
|
||
}
|
||
}
|
||
|
||
if metadata.Image == "" {
|
||
twitterImageRe := regexp.MustCompile(`(?i)<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])
|
||
}
|
||
}
|
||
}
|
||
|
||
// Поиск цены
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
// Нормализуем URL изображения
|
||
if metadata.Image != "" && !strings.HasPrefix(metadata.Image, "http") {
|
||
baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
|
||
if strings.HasPrefix(metadata.Image, "//") {
|
||
metadata.Image = parsedURL.Scheme + ":" + metadata.Image
|
||
} else if strings.HasPrefix(metadata.Image, "/") {
|
||
metadata.Image = baseURL + metadata.Image
|
||
} else {
|
||
metadata.Image = baseURL + "/" + metadata.Image
|
||
}
|
||
}
|
||
|
||
metadata.Title = html.UnescapeString(metadata.Title)
|
||
metadata.Description = html.UnescapeString(metadata.Description)
|
||
|
||
return metadata, nil
|
||
}
|
||
|
||
// extractMetadataViaChrome извлекает метаданные через headless Chrome
|
||
// Используется как fallback для JavaScript-рендеринга страниц
|
||
func extractMetadataViaChrome(targetURL string) (*LinkMetadataResponse, error) {
|
||
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||
chromedp.Flag("headless", true),
|
||
chromedp.Flag("disable-gpu", true),
|
||
chromedp.Flag("no-sandbox", true),
|
||
chromedp.Flag("disable-dev-shm-usage", true),
|
||
)
|
||
|
||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
||
defer cancel()
|
||
|
||
ctx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf))
|
||
defer cancel()
|
||
|
||
ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
|
||
defer cancel()
|
||
|
||
metadata := &LinkMetadataResponse{}
|
||
|
||
// Используем map для получения данных из JavaScript
|
||
var result map[string]interface{}
|
||
|
||
err := chromedp.Run(ctx,
|
||
chromedp.Navigate(targetURL),
|
||
chromedp.WaitVisible("body", chromedp.ByQuery),
|
||
chromedp.Sleep(2*time.Second), // Даем время на выполнение JavaScript
|
||
chromedp.Evaluate(`
|
||
(function() {
|
||
const result = {
|
||
title: '',
|
||
image: '',
|
||
description: '',
|
||
price: null
|
||
};
|
||
|
||
// Извлекаем og:title
|
||
const ogTitle = document.querySelector('meta[property="og:title"]');
|
||
if (ogTitle) {
|
||
result.title = ogTitle.getAttribute('content') || '';
|
||
} else {
|
||
// Fallback на обычный title
|
||
const titleEl = document.querySelector('title');
|
||
if (titleEl) {
|
||
result.title = titleEl.textContent || '';
|
||
}
|
||
}
|
||
|
||
// Извлекаем og:image
|
||
const ogImage = document.querySelector('meta[property="og:image"]');
|
||
if (ogImage) {
|
||
result.image = ogImage.getAttribute('content') || '';
|
||
} else {
|
||
// Fallback на twitter:image
|
||
const twitterImage = document.querySelector('meta[name="twitter:image"]');
|
||
if (twitterImage) {
|
||
result.image = twitterImage.getAttribute('content') || '';
|
||
}
|
||
}
|
||
|
||
// Извлекаем og:description
|
||
const ogDesc = document.querySelector('meta[property="og:description"]');
|
||
if (ogDesc) {
|
||
result.description = ogDesc.getAttribute('content') || '';
|
||
}
|
||
|
||
// Извлекаем цену из JSON-LD
|
||
const jsonLdScripts = document.querySelectorAll('script[type="application/ld+json"]');
|
||
for (const script of jsonLdScripts) {
|
||
try {
|
||
const data = JSON.parse(script.textContent);
|
||
if (data.offers && data.offers.price) {
|
||
result.price = parseFloat(data.offers.price);
|
||
break;
|
||
}
|
||
if (data.price) {
|
||
result.price = parseFloat(data.price);
|
||
break;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
// Если не нашли в JSON-LD, ищем в meta тегах
|
||
if (!result.price) {
|
||
const priceMeta = document.querySelector('meta[property="product:price:amount"]');
|
||
if (priceMeta) {
|
||
result.price = parseFloat(priceMeta.getAttribute('content'));
|
||
}
|
||
}
|
||
|
||
// Нормализуем URL изображения
|
||
if (result.image && !result.image.startsWith('http')) {
|
||
const baseURL = window.location.origin;
|
||
if (result.image.startsWith('//')) {
|
||
result.image = window.location.protocol + result.image;
|
||
} else if (result.image.startsWith('/')) {
|
||
result.image = baseURL + result.image;
|
||
} else {
|
||
result.image = baseURL + '/' + result.image;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
})();
|
||
`, &result),
|
||
)
|
||
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error extracting metadata via Chrome: %w", err)
|
||
}
|
||
|
||
// Преобразуем map в структуру
|
||
if title, ok := result["title"].(string); ok {
|
||
metadata.Title = strings.TrimSpace(title)
|
||
}
|
||
if image, ok := result["image"].(string); ok {
|
||
metadata.Image = strings.TrimSpace(image)
|
||
}
|
||
if desc, ok := result["description"].(string); ok {
|
||
metadata.Description = strings.TrimSpace(desc)
|
||
}
|
||
if priceVal := result["price"]; priceVal != nil {
|
||
if priceFloat, ok := priceVal.(float64); ok {
|
||
if priceFloat > 0 && priceFloat < 100000000 {
|
||
metadata.Price = &priceFloat
|
||
}
|
||
}
|
||
}
|
||
|
||
// Валидация и очистка данных
|
||
if metadata.Title != "" {
|
||
metadata.Title = strings.TrimSpace(metadata.Title)
|
||
if strings.Contains(strings.ToLower(metadata.Title), "робот") ||
|
||
strings.Contains(strings.ToLower(metadata.Title), "captcha") ||
|
||
strings.Contains(strings.ToLower(metadata.Title), "вы не робот") {
|
||
metadata.Title = ""
|
||
metadata.Image = ""
|
||
metadata.Description = ""
|
||
}
|
||
}
|
||
|
||
if metadata.Price != nil && (*metadata.Price <= 0 || *metadata.Price >= 100000000) {
|
||
metadata.Price = nil
|
||
}
|
||
|
||
return metadata, nil
|
||
}
|
||
|
||
// extractLinkMetadataHandler извлекает метаданные (Open Graph, Title, Image) из URL
|
||
// Использует HTTP-метод как основной (стандартный подход), chromedp как fallback
|
||
func (a *App) extractLinkMetadataHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
var req struct {
|
||
URL string `json:"url"`
|
||
}
|
||
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
log.Printf("Error decoding metadata request body: %v", err)
|
||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
if req.URL == "" {
|
||
log.Printf("Empty URL in metadata request")
|
||
sendErrorWithCORS(w, "URL is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Валидация URL
|
||
_, err := url.Parse(req.URL)
|
||
if err != nil {
|
||
log.Printf("Invalid URL format: %s, error: %v", req.URL, err)
|
||
sendErrorWithCORS(w, "Invalid URL", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
log.Printf("Extracting metadata for URL: %s", req.URL)
|
||
|
||
// Шаг 1: Пытаемся получить метаданные через HTTP-метод (основной, быстрый метод)
|
||
metadata, err := extractMetadataViaHTTP(req.URL)
|
||
if err != nil {
|
||
log.Printf("HTTP method failed for URL %s: %v", req.URL, err)
|
||
metadata = &LinkMetadataResponse{} // Инициализируем пустую структуру для fallback
|
||
}
|
||
|
||
// Шаг 2: Проверяем, достаточно ли данных из HTTP-метода
|
||
// Если нет title и image, используем chromedp fallback
|
||
needsFallback := (metadata.Title == "" && metadata.Image == "")
|
||
|
||
if needsFallback {
|
||
log.Printf("HTTP method didn't return enough data for URL %s, trying Chrome fallback", req.URL)
|
||
chromeMetadata, chromeErr := extractMetadataViaChrome(req.URL)
|
||
if chromeErr != nil {
|
||
log.Printf("Chrome fallback failed for URL %s: %v", req.URL, chromeErr)
|
||
// Возвращаем результаты HTTP-метода, даже если они пустые
|
||
} else {
|
||
// Объединяем результаты: приоритет у HTTP, дополняем из Chrome
|
||
if metadata.Title == "" && chromeMetadata.Title != "" {
|
||
metadata.Title = chromeMetadata.Title
|
||
log.Printf("Chrome fallback provided title: %s", chromeMetadata.Title)
|
||
}
|
||
if metadata.Image == "" && chromeMetadata.Image != "" {
|
||
metadata.Image = chromeMetadata.Image
|
||
log.Printf("Chrome fallback provided image: %s", chromeMetadata.Image)
|
||
}
|
||
if metadata.Description == "" && chromeMetadata.Description != "" {
|
||
metadata.Description = chromeMetadata.Description
|
||
}
|
||
if metadata.Price == nil && chromeMetadata.Price != nil {
|
||
metadata.Price = chromeMetadata.Price
|
||
}
|
||
}
|
||
} else {
|
||
log.Printf("HTTP method successfully extracted metadata for URL %s", req.URL)
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json")
|
||
json.NewEncoder(w).Encode(metadata)
|
||
}
|
||
|
||
// proxyImageHandler проксирует изображение через бэкенд для обхода CORS
|
||
func (a *App) proxyImageHandler(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == "OPTIONS" {
|
||
setCORSHeaders(w)
|
||
w.WriteHeader(http.StatusOK)
|
||
return
|
||
}
|
||
setCORSHeaders(w)
|
||
|
||
userID, ok := getUserIDFromContext(r)
|
||
if !ok {
|
||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||
return
|
||
}
|
||
|
||
imageURL := r.URL.Query().Get("url")
|
||
if imageURL == "" {
|
||
sendErrorWithCORS(w, "URL parameter is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Валидация URL
|
||
parsedURL, err := url.Parse(imageURL)
|
||
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
|
||
log.Printf("Invalid image URL: %s", imageURL)
|
||
sendErrorWithCORS(w, "Invalid URL", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
log.Printf("Proxying image for user %d: %s", userID, imageURL)
|
||
|
||
// Создаем HTTP запрос к изображению
|
||
client := &http.Client{
|
||
Timeout: 30 * time.Second,
|
||
}
|
||
|
||
req, err := http.NewRequest("GET", imageURL, nil)
|
||
if err != nil {
|
||
log.Printf("Error creating image request: %v", err)
|
||
sendErrorWithCORS(w, "Error creating request", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Устанавливаем User-Agent для имитации браузера
|
||
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||
req.Header.Set("Referer", parsedURL.Scheme+"://"+parsedURL.Host+"/")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
log.Printf("Error fetching image: %v", err)
|
||
sendErrorWithCORS(w, fmt.Sprintf("Error fetching image: %v", err), http.StatusBadGateway)
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
log.Printf("Image fetch returned status %d", resp.StatusCode)
|
||
sendErrorWithCORS(w, fmt.Sprintf("HTTP %d", resp.StatusCode), http.StatusBadGateway)
|
||
return
|
||
}
|
||
|
||
// Ограничиваем размер (максимум 5MB)
|
||
limitedReader := io.LimitReader(resp.Body, 5*1024*1024)
|
||
bodyBytes, err := io.ReadAll(limitedReader)
|
||
if err != nil {
|
||
log.Printf("Error reading image: %v", err)
|
||
sendErrorWithCORS(w, "Error reading image", http.StatusInternalServerError)
|
||
return
|
||
}
|
||
|
||
// Определяем Content-Type
|
||
contentType := resp.Header.Get("Content-Type")
|
||
if contentType == "" {
|
||
// Пытаемся определить по содержимому
|
||
if len(bodyBytes) >= 2 {
|
||
if bodyBytes[0] == 0xFF && bodyBytes[1] == 0xD8 {
|
||
contentType = "image/jpeg"
|
||
} else if len(bodyBytes) >= 8 && string(bodyBytes[0:8]) == "\x89PNG\r\n\x1a\n" {
|
||
contentType = "image/png"
|
||
} else if len(bodyBytes) >= 4 && string(bodyBytes[0:4]) == "RIFF" {
|
||
contentType = "image/webp"
|
||
} else {
|
||
contentType = "application/octet-stream"
|
||
}
|
||
}
|
||
}
|
||
|
||
// Проверяем, что это изображение
|
||
if !strings.HasPrefix(contentType, "image/") {
|
||
log.Printf("Invalid content type: %s", contentType)
|
||
sendErrorWithCORS(w, "Not an image", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
// Отправляем изображение
|
||
w.Header().Set("Content-Type", contentType)
|
||
w.Header().Set("Content-Length", strconv.Itoa(len(bodyBytes)))
|
||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||
w.WriteHeader(http.StatusOK)
|
||
w.Write(bodyBytes)
|
||
}
|
||
|
||
// 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
|
||
}
|