Files
play-life/play-life-backend/main.go
poignatov b3a83e1e8f
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m5s
feat: замена period_type на start_date в wishlist, обновление UI формы условий
- Добавлена миграция 020 для замены period_type на start_date в score_conditions
- Обновлена функция подсчёта баллов: calculateProjectPointsFromDate вместо calculateProjectPointsForPeriod
- Добавлен компонент DateSelector для выбора даты начала подсчёта
- По умолчанию выбран тип условия 'Баллы'
- Переименованы опции: 'Баллы' и 'Задача'
- Версия: 3.9.3
2026-01-12 17:02:33 +03:00

9781 lines
308 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf16"
"github.com/disintegration/imaging"
"github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/golang-jwt/jwt/v5"
"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"
"image/jpeg"
)
type Word struct {
ID int `json:"id"`
Name string `json:"name"`
Translation string `json:"translation"`
Description string `json:"description"`
Success int `json:"success"`
Failure int `json:"failure"`
LastSuccess *string `json:"last_success_at,omitempty"`
LastFailure *string `json:"last_failure_at,omitempty"`
}
type WordRequest struct {
Name string `json:"name"`
Translation string `json:"translation"`
Description string `json:"description"`
DictionaryID *int `json:"dictionary_id,omitempty"`
}
type WordsRequest struct {
Words []WordRequest `json:"words"`
}
type TestProgressUpdate struct {
ID int `json:"id"`
Success int `json:"success"`
Failure int `json:"failure"`
LastSuccessAt *string `json:"last_success_at,omitempty"`
LastFailureAt *string `json:"last_failure_at,omitempty"`
}
type TestProgressRequest struct {
Words []TestProgressUpdate `json:"words"`
ConfigID *int `json:"config_id,omitempty"`
}
type Config struct {
ID int `json:"id"`
Name string `json:"name"`
WordsCount int `json:"words_count"`
MaxCards *int `json:"max_cards,omitempty"`
TryMessage string `json:"try_message"`
}
type ConfigRequest struct {
Name string `json:"name"`
WordsCount int `json:"words_count"`
MaxCards *int `json:"max_cards,omitempty"`
TryMessage string `json:"try_message"`
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
}
type Dictionary struct {
ID int `json:"id"`
Name string `json:"name"`
WordsCount int `json:"wordsCount"`
}
type DictionaryRequest struct {
Name string `json:"name"`
}
type TestConfigsAndDictionariesResponse struct {
Configs []Config `json:"configs"`
Dictionaries []Dictionary `json:"dictionaries"`
}
type WeeklyProjectStats struct {
ProjectName string `json:"project_name"`
TotalScore float64 `json:"total_score"`
MinGoalScore float64 `json:"min_goal_score"`
MaxGoalScore *float64 `json:"max_goal_score,omitempty"`
Priority *int `json:"priority,omitempty"`
CalculatedScore float64 `json:"calculated_score"`
}
type WeeklyStatsResponse struct {
Total *float64 `json:"total,omitempty"`
Projects []WeeklyProjectStats `json:"projects"`
}
type MessagePostRequest struct {
Body struct {
Text string `json:"text"`
} `json:"body"`
}
type ProcessedNode struct {
Project string `json:"project"`
Score float64 `json:"score"`
}
type ProcessedEntry struct {
Text string `json:"text"`
CreatedDate string `json:"createdDate"`
Nodes []ProcessedNode `json:"nodes"`
Raw string `json:"raw"`
Markdown string `json:"markdown"`
}
type WeeklyGoalSetup struct {
ProjectName string `json:"project_name"`
MinGoalScore float64 `json:"min_goal_score"`
MaxGoalScore float64 `json:"max_goal_score"`
}
type Project struct {
ProjectID int `json:"project_id"`
ProjectName string `json:"project_name"`
Priority *int `json:"priority,omitempty"`
}
type ProjectPriorityUpdate struct {
ID int `json:"id"`
Priority *int `json:"priority"`
}
type ProjectPriorityRequest struct {
Body []ProjectPriorityUpdate `json:"body"`
}
type FullStatisticsItem struct {
ProjectName string `json:"project_name"`
ReportYear int `json:"report_year"`
ReportWeek int `json:"report_week"`
TotalScore float64 `json:"total_score"`
MinGoalScore float64 `json:"min_goal_score"`
MaxGoalScore float64 `json:"max_goal_score"`
}
type TodoistWebhook struct {
EventName string `json:"event_name"`
EventData map[string]interface{} `json:"event_data"`
}
type TelegramEntity struct {
Type string `json:"type"`
Offset int `json:"offset"`
Length int `json:"length"`
}
type 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"`
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
ProjectNames []string `json:"project_names"`
SubtasksCount int `json:"subtasks_count"`
HasProgression bool `json:"has_progression"`
}
type Reward struct {
ID int `json:"id"`
Position int `json:"position"`
ProjectName string `json:"project_name"`
Value float64 `json:"value"`
UseProgression bool `json:"use_progression"`
}
type Subtask struct {
Task Task `json:"task"`
Rewards []Reward `json:"rewards"`
}
type TaskDetail struct {
Task Task `json:"task"`
Rewards []Reward `json:"rewards"`
Subtasks []Subtask `json:"subtasks"`
}
type RewardRequest struct {
Position int `json:"position"`
ProjectName string `json:"project_name"`
Value float64 `json:"value"`
UseProgression bool `json:"use_progression"`
}
type SubtaskRequest struct {
ID *int `json:"id,omitempty"`
Name *string `json:"name,omitempty"`
RewardMessage *string `json:"reward_message,omitempty"`
Rewards []RewardRequest `json:"rewards,omitempty"`
}
type TaskRequest struct {
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"`
Rewards []RewardRequest `json:"rewards,omitempty"`
Subtasks []SubtaskRequest `json:"subtasks,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"`
}
// ============================================
// Wishlist structures
// ============================================
type WishlistItem struct {
ID int `json:"id"`
Name string `json:"name"`
Price *float64 `json:"price,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
Link *string `json:"link,omitempty"`
Unlocked bool `json:"unlocked"`
Completed bool `json:"completed"`
FirstLockedCondition *UnlockConditionDisplay `json:"first_locked_condition,omitempty"`
MoreLockedConditions int `json:"more_locked_conditions,omitempty"`
UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
}
type UnlockConditionDisplay struct {
ID int `json:"id"`
Type string `json:"type"`
TaskName *string `json:"task_name,omitempty"`
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)
}
type WishlistRequest struct {
Name string `json:"name"`
Price *float64 `json:"price,omitempty"`
Link *string `json:"link,omitempty"`
UnlockConditions []UnlockConditionRequest `json:"unlock_conditions,omitempty"`
}
type UnlockConditionRequest struct {
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"`
}
// ============================================
// 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"`
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
}
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))
})
}
// ============================================
// 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)
VALUES ($1, $2, $3, NOW(), NOW(), true)
RETURNING id, email, name, created_at, updated_at, is_active, last_login_at
`, req.Email, passwordHash, req.Name).Scan(
&user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &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, 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.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.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.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
}
// Delete old refresh token
a.DB.Exec("DELETE FROM refresh_tokens WHERE id = $1", foundTokenID)
// Generate new 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
}
// Store new refresh token
refreshTokenHash, _ := hashPassword(refreshToken)
a.DB.Exec(`
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
`, user.ID, refreshTokenHash, nil)
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, last_login_at
FROM users WHERE id = $1
`, userID).Scan(
&user.ID, &user.Email, &user.Name, &user.CreatedAt, &user.UpdatedAt, &user.IsActive, &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
}
// If config_id is provided, send webhook with try_message
if req.ConfigID != nil {
configID := *req.ConfigID
// Use mutex to prevent duplicate webhook sends
a.webhookMutex.Lock()
lastTime, exists := a.lastWebhookTime[configID]
now := time.Now()
// Only send webhook if it hasn't been sent in the last 5 seconds for this config
shouldSend := !exists || now.Sub(lastTime) > 5*time.Second
if shouldSend {
a.lastWebhookTime[configID] = now
}
a.webhookMutex.Unlock()
if !shouldSend {
log.Printf("Webhook skipped for config_id %d (sent recently)", configID)
} else {
var tryMessage sql.NullString
err := a.DB.QueryRow("SELECT try_message FROM configs WHERE id = $1", configID).Scan(&tryMessage)
if err == nil && tryMessage.Valid && tryMessage.String != "" {
// Process message directly (backend always runs together with frontend)
_, err := a.processMessage(tryMessage.String, &userID)
if err != nil {
log.Printf("Error processing message: %v", err)
// Remove from map on error so it can be retried
a.webhookMutex.Lock()
delete(a.lastWebhookTime, configID)
a.webhookMutex.Unlock()
} else {
log.Printf("Message processed successfully for config_id %d", configID)
}
} else if err != nil && err != sql.ErrNoRows {
log.Printf("Error fetching config: %v", err)
} else if err == nil && (!tryMessage.Valid || tryMessage.String == "") {
log.Printf("Webhook skipped for config_id %d (try_message is empty)", configID)
}
}
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Progress updated successfully",
})
}
func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
query := `
SELECT id, name, words_count, max_cards, try_message
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.Name,
&config.WordsCount,
&maxCards,
&config.TryMessage,
)
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, name, words_count, max_cards, try_message
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.Name,
&config.WordsCount,
&maxCards,
&config.TryMessage,
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if maxCards.Valid {
maxCardsVal := int(maxCards.Int64)
config.MaxCards = &maxCardsVal
}
configs = append(configs, config)
}
// Get dictionaries
dictsQuery := `
SELECT
d.id,
d.name,
COALESCE(COUNT(w.id), 0) as words_count
FROM dictionaries d
LEFT JOIN words w ON d.id = w.dictionary_id
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.Name == "" {
sendErrorWithCORS(w, "Имя обязательно для заполнения", 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 (name, words_count, max_cards, try_message, user_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, 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.Name == "" {
sendErrorWithCORS(w, "Имя обязательно для заполнения", 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 name = $1, words_count = $2, max_cards = $3, try_message = $4
WHERE id = $5
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, configID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if rowsAffected == 0 {
http.Error(w, "Config not found", http.StatusNotFound)
return
}
// Delete existing dictionary associations
_, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", configID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Insert new dictionary associations if provided
if len(req.DictionaryIDs) > 0 {
stmt, err := tx.Prepare(`
INSERT INTO config_dictionaries (config_id, dictionary_id)
VALUES ($1, $2)
`)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer stmt.Close()
for _, dictID := range req.DictionaryIDs {
_, err := stmt.Exec(configID, dictID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
if err := tx.Commit(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Config updated successfully",
})
}
func (a *App) deleteConfigHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
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)
// Опционально обновляем materialized view перед запросом
// Это можно сделать через query parameter ?refresh=true
if r.URL.Query().Get("refresh") == "true" {
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
// Продолжаем выполнение даже если обновление не удалось
}
}
query := `
SELECT
p.name AS project_name,
-- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv
COALESCE(wr.total_score, 0.0000) AS total_score,
wg.min_goal_score,
wg.max_goal_score,
wg.priority AS priority
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 minGoalScore sql.NullFloat64
var maxGoalScore sql.NullFloat64
var priority sql.NullInt64
err := rows.Scan(
&project.ProjectName,
&project.TotalScore,
&minGoalScore,
&maxGoalScore,
&priority,
)
if err != nil {
log.Printf("Error scanning weekly stats row: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
if minGoalScore.Valid {
project.MinGoalScore = minGoalScore.Float64
} else {
project.MinGoalScore = 0
}
if maxGoalScore.Valid {
maxGoalVal := maxGoalScore.Float64
project.MaxGoalScore = &maxGoalVal
}
var priorityVal int
if priority.Valid {
priorityVal = int(priority.Int64)
project.Priority = &priorityVal
}
// Расчет calculated_score по формуле из n8n
totalScore := project.TotalScore
minGoalScoreVal := project.MinGoalScore
var maxGoalScoreVal float64
if project.MaxGoalScore != nil {
maxGoalScoreVal = *project.MaxGoalScore
}
// Параметры бонуса в зависимости от priority
var extraBonusLimit float64 = 20
if priorityVal == 1 {
extraBonusLimit = 50
} else if priorityVal == 2 {
extraBonusLimit = 35
}
// Расчет базового прогресса
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)
}
// Вычисляем общий процент выполнения
total := calculateOverallProgress(groups)
response := WeeklyStatsResponse{
Total: total,
Projects: projects,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (a *App) initDB() error {
createDictionariesTable := `
CREATE TABLE IF NOT EXISTS dictionaries (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
)
`
createWordsTable := `
CREATE TABLE IF NOT EXISTS words (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
translation TEXT NOT NULL,
description TEXT
)
`
createProgressTable := `
CREATE TABLE IF NOT EXISTS progress (
id SERIAL PRIMARY KEY,
word_id INTEGER NOT NULL REFERENCES words(id) ON DELETE CASCADE,
success INTEGER DEFAULT 0,
failure INTEGER DEFAULT 0,
last_success_at TIMESTAMP,
last_failure_at TIMESTAMP,
UNIQUE(word_id)
)
`
createConfigsTable := `
CREATE TABLE IF NOT EXISTS configs (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
words_count INTEGER NOT NULL,
max_cards INTEGER,
try_message TEXT
)
`
createConfigDictionariesTable := `
CREATE TABLE IF NOT EXISTS config_dictionaries (
config_id INTEGER NOT NULL REFERENCES configs(id) ON DELETE CASCADE,
dictionary_id INTEGER NOT NULL REFERENCES dictionaries(id) ON DELETE CASCADE,
PRIMARY KEY (config_id, dictionary_id)
)
`
createConfigDictionariesIndexes := []string{
`CREATE INDEX IF NOT EXISTS idx_config_dictionaries_config_id ON config_dictionaries(config_id)`,
`CREATE INDEX IF NOT EXISTS idx_config_dictionaries_dictionary_id ON config_dictionaries(dictionary_id)`,
}
// Alter existing table to make try_message nullable if it's not already
alterConfigsTable := `
ALTER TABLE configs
ALTER COLUMN try_message DROP NOT NULL
`
// Alter existing table to add max_cards column if it doesn't exist
alterConfigsTableMaxCards := `
ALTER TABLE configs
ADD COLUMN IF NOT EXISTS max_cards INTEGER
`
// Create dictionaries table first
if _, err := a.DB.Exec(createDictionariesTable); err != nil {
return err
}
// Insert default dictionary "Все слова" with id = 0
// PostgreSQL SERIAL starts from 1, so we need to set sequence to -1 first
insertDefaultDictionary := `
DO $$
BEGIN
-- Set sequence to -1 so next value will be 0
PERFORM setval('dictionaries_id_seq', -1, false);
-- Insert the default dictionary with id = 0
INSERT INTO dictionaries (id, name)
VALUES (0, 'Все слова')
ON CONFLICT (id) DO NOTHING;
-- Set the sequence to start from 1 (so next auto-increment will be 1)
PERFORM setval('dictionaries_id_seq', 1, false);
EXCEPTION
WHEN others THEN
-- If sequence doesn't exist or other error, try without sequence manipulation
INSERT INTO dictionaries (id, name)
VALUES (0, 'Все слова')
ON CONFLICT (id) DO NOTHING;
END $$;
`
if _, err := a.DB.Exec(insertDefaultDictionary); err != nil {
log.Printf("Warning: Failed to insert default dictionary: %v. Trying alternative method.", err)
// Alternative: try to insert without sequence manipulation
_, err2 := a.DB.Exec(`INSERT INTO dictionaries (id, name) VALUES (0, 'Все слова') ON CONFLICT (id) DO NOTHING`)
if err2 != nil {
log.Printf("Warning: Alternative insert also failed: %v", err2)
}
}
if _, err := a.DB.Exec(createWordsTable); err != nil {
return err
}
// Add dictionary_id column to words if it doesn't exist
// First check if column exists, if not add it
checkColumnExists := `
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_name='words' AND column_name='dictionary_id'
`
var columnExists int
err := a.DB.QueryRow(checkColumnExists).Scan(&columnExists)
if err == nil && columnExists == 0 {
// Column doesn't exist, add it
alterWordsTable := `
ALTER TABLE words
ADD COLUMN dictionary_id INTEGER DEFAULT 0
`
if _, err := a.DB.Exec(alterWordsTable); err != nil {
log.Printf("Warning: Failed to add dictionary_id column: %v", err)
} else {
// Add foreign key constraint
addForeignKey := `
ALTER TABLE words
ADD CONSTRAINT words_dictionary_id_fkey
FOREIGN KEY (dictionary_id) REFERENCES dictionaries(id)
`
a.DB.Exec(addForeignKey)
}
}
// Update existing words to have dictionary_id = 0
updateWordsDictionaryID := `
UPDATE words
SET dictionary_id = 0
WHERE dictionary_id IS NULL
`
a.DB.Exec(updateWordsDictionaryID)
// Make dictionary_id NOT NULL after setting default values (if column exists)
if columnExists > 0 || err == nil {
alterWordsTableNotNull := `
DO $$
BEGIN
ALTER TABLE words
ALTER COLUMN dictionary_id SET NOT NULL,
ALTER COLUMN dictionary_id SET DEFAULT 0;
EXCEPTION
WHEN others THEN
-- Ignore if already NOT NULL
NULL;
END $$;
`
a.DB.Exec(alterWordsTableNotNull)
}
// Create index on dictionary_id
createDictionaryIndex := `
CREATE INDEX IF NOT EXISTS idx_words_dictionary_id ON words(dictionary_id)
`
a.DB.Exec(createDictionaryIndex)
// Remove unique constraint on words.name if it exists
removeUniqueConstraint := `
ALTER TABLE words
DROP CONSTRAINT IF EXISTS words_name_key;
ALTER TABLE words
DROP CONSTRAINT IF EXISTS words_name_unique;
`
a.DB.Exec(removeUniqueConstraint)
if _, err := a.DB.Exec(createProgressTable); err != nil {
return err
}
if _, err := a.DB.Exec(createConfigsTable); err != nil {
return err
}
// Try to alter existing table to make try_message nullable
// Ignore error if column is already nullable or table doesn't exist
a.DB.Exec(alterConfigsTable)
// Try to alter existing table to add max_cards column
// Ignore error if column already exists
a.DB.Exec(alterConfigsTableMaxCards)
// Create config_dictionaries table
if _, err := a.DB.Exec(createConfigDictionariesTable); err != nil {
return err
}
// Create indexes for config_dictionaries
for _, indexSQL := range createConfigDictionariesIndexes {
if _, err := a.DB.Exec(indexSQL); err != nil {
log.Printf("Warning: Failed to create config_dictionaries index: %v", err)
}
}
return nil
}
func (a *App) initAuthDB() error {
// Create users table
createUsersTable := `
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT TRUE,
last_login_at TIMESTAMP WITH TIME ZONE
)
`
if _, err := a.DB.Exec(createUsersTable); err != nil {
return err
}
// Create index on email
a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)")
// Create refresh_tokens table
createRefreshTokensTable := `
CREATE TABLE IF NOT EXISTS refresh_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(255) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
)
`
if _, err := a.DB.Exec(createRefreshTokensTable); err != nil {
return err
}
// Create indexes for refresh_tokens
a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id)")
a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token_hash ON refresh_tokens(token_hash)")
// Add user_id column to all tables if not exists
tables := []string{"projects", "entries", "nodes", "dictionaries", "words", "progress", "configs", "telegram_integrations", "weekly_goals", "tasks"}
for _, table := range tables {
alterSQL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE", table)
if _, err := a.DB.Exec(alterSQL); err != nil {
log.Printf("Warning: Failed to add user_id to %s: %v", table, err)
}
indexSQL := fmt.Sprintf("CREATE INDEX IF NOT EXISTS idx_%s_user_id ON %s(user_id)", table, table)
a.DB.Exec(indexSQL)
}
// Drop old unique constraint on projects.name (now unique per user, not globally)
a.DB.Exec("ALTER TABLE projects DROP CONSTRAINT IF EXISTS unique_project_name")
// Drop old unique constraint on progress.word_id (now unique per user)
a.DB.Exec("ALTER TABLE progress DROP CONSTRAINT IF EXISTS progress_word_id_key")
// Create new unique constraint per user for progress
a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_progress_word_user_unique ON progress(word_id, user_id)")
// Add webhook_token to telegram_integrations for URL-based user identification (legacy, will be removed in migration 012)
a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS webhook_token VARCHAR(255)")
a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_webhook_token ON telegram_integrations(webhook_token) WHERE webhook_token IS NOT NULL")
// Apply migration 012: Refactor telegram_integrations for single shared bot
if err := a.applyMigration012(); err != nil {
log.Printf("Warning: Failed to apply migration 012: %v", err)
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
// Apply migration 013: Refactor todoist_integrations for single Todoist app
if err := a.applyMigration013(); err != nil {
log.Printf("Warning: Failed to apply migration 013: %v", err)
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
// Apply migration 019: Add wishlist tables
if err := a.applyMigration019(); err != nil {
log.Printf("Warning: Failed to apply migration 019: %v", err)
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
// Apply migration 020: Change period_type to start_date in score_conditions
if err := a.applyMigration020(); err != nil {
log.Printf("Warning: Failed to apply migration 020: %v", err)
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
// Clean up expired refresh tokens (only those with expiration date set)
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()")
return nil
}
// applyMigration012 применяет миграцию 012_refactor_telegram_single_bot.sql
func (a *App) applyMigration012() error {
log.Printf("Applying migration 012: Refactor telegram_integrations for single shared bot")
// 1. Создаем таблицу todoist_integrations
createTodoistIntegrationsTable := `
CREATE TABLE IF NOT EXISTS todoist_integrations (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
webhook_token VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id)
)
`
if _, err := a.DB.Exec(createTodoistIntegrationsTable); err != nil {
return fmt.Errorf("failed to create todoist_integrations table: %w", err)
}
// Создаем индексы для todoist_integrations
a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_webhook_token ON todoist_integrations(webhook_token)")
a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_todoist_integrations_user_id ON todoist_integrations(user_id)")
// 2. Мигрируем webhook_token из telegram_integrations в todoist_integrations
migrateWebhookTokens := `
INSERT INTO todoist_integrations (user_id, webhook_token, created_at, updated_at)
SELECT user_id, webhook_token, COALESCE(created_at, CURRENT_TIMESTAMP), CURRENT_TIMESTAMP
FROM telegram_integrations
WHERE webhook_token IS NOT NULL
AND webhook_token != ''
AND user_id IS NOT NULL
ON CONFLICT (user_id) DO NOTHING
`
if _, err := a.DB.Exec(migrateWebhookTokens); err != nil {
log.Printf("Warning: Failed to migrate webhook_token to todoist_integrations: %v", err)
// Продолжаем выполнение, так как это может быть уже выполнено
}
// 3. Удаляем bot_token (будет в .env)
a.DB.Exec("ALTER TABLE telegram_integrations DROP COLUMN IF EXISTS bot_token")
// 4. Удаляем webhook_token (перенесли в todoist_integrations)
a.DB.Exec("ALTER TABLE telegram_integrations DROP COLUMN IF EXISTS webhook_token")
// 5. Добавляем telegram_user_id
a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS telegram_user_id BIGINT")
// 6. Добавляем start_token
a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS start_token VARCHAR(255)")
// 7. Добавляем timestamps если их нет
a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
a.DB.Exec("ALTER TABLE telegram_integrations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP")
// 8. Создаем индексы
a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_start_token ON telegram_integrations(start_token) WHERE start_token IS NOT NULL")
a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_telegram_user_id ON telegram_integrations(telegram_user_id) WHERE telegram_user_id IS NOT NULL")
// Уникальность user_id
a.DB.Exec("DROP INDEX IF EXISTS idx_telegram_integrations_user_id")
a.DB.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_telegram_integrations_user_id_unique ON telegram_integrations(user_id) WHERE user_id IS NOT NULL")
// Индекс для поиска по chat_id
a.DB.Exec("CREATE INDEX IF NOT EXISTS idx_telegram_integrations_chat_id ON telegram_integrations(chat_id) WHERE chat_id IS NOT NULL")
// Удаляем старый индекс webhook_token
a.DB.Exec("DROP INDEX IF EXISTS idx_telegram_integrations_webhook_token")
// 9. Очищаем данные Telegram для переподключения (только если еще не очищены)
// Проверяем, есть ли записи с заполненными chat_id или telegram_user_id
var count int
err := a.DB.QueryRow(`
SELECT COUNT(*) FROM telegram_integrations
WHERE (chat_id IS NOT NULL OR telegram_user_id IS NOT NULL)
AND (start_token IS NULL OR start_token = '')
`).Scan(&count)
// Если есть старые данные без start_token, очищаем их для переподключения
if err == nil && count > 0 {
log.Printf("Clearing old Telegram integration data for %d users (they will need to reconnect)", count)
a.DB.Exec(`
UPDATE telegram_integrations
SET chat_id = NULL,
telegram_user_id = NULL,
start_token = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE (chat_id IS NOT NULL OR telegram_user_id IS NOT NULL)
AND (start_token IS NULL OR start_token = '')
`)
}
log.Printf("Migration 012 applied successfully")
return nil
}
// applyMigration013 применяет миграцию 013_refactor_todoist_single_app.sql
func (a *App) applyMigration013() error {
log.Printf("Applying migration 013: Refactor todoist_integrations for single Todoist app")
// 1. Добавляем новые поля
a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS todoist_user_id BIGINT")
a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS todoist_email VARCHAR(255)")
a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS access_token TEXT")
// 2. Удаляем webhook_token
a.DB.Exec("ALTER TABLE todoist_integrations DROP COLUMN IF EXISTS webhook_token")
// 3. Удаляем старый индекс
a.DB.Exec("DROP INDEX IF EXISTS idx_todoist_integrations_webhook_token")
// 4. Создаем новые индексы
a.DB.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_user_id
ON todoist_integrations(todoist_user_id)
WHERE todoist_user_id IS NOT NULL
`)
a.DB.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email
ON todoist_integrations(todoist_email)
WHERE todoist_email IS NOT NULL
`)
log.Printf("Migration 013 applied successfully")
return nil
}
// applyMigration019 применяет миграцию 019_add_wishlist.sql
func (a *App) applyMigration019() error {
log.Printf("Applying migration 019: Add wishlist tables")
// Проверяем, существует ли уже таблица wishlist_items
var exists bool
err := a.DB.QueryRow(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'wishlist_items'
)
`).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check if wishlist_items exists: %w", err)
}
if exists {
log.Printf("Migration 019 already applied (wishlist_items table exists), skipping")
return nil
}
// Читаем SQL файл миграции
migrationPath := "/migrations/019_add_wishlist.sql"
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
// Пробуем альтернативный путь (для локальной разработки)
migrationPath = "play-life-backend/migrations/019_add_wishlist.sql"
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
migrationPath = "migrations/019_add_wishlist.sql"
}
}
migrationSQL, err := os.ReadFile(migrationPath)
if err != nil {
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
}
// Выполняем миграцию
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
return fmt.Errorf("failed to execute migration 019: %w", err)
}
log.Printf("Migration 019 applied successfully")
return nil
}
// applyMigration020 применяет миграцию 020_change_period_to_start_date.sql
func (a *App) applyMigration020() error {
log.Printf("Applying migration 020: Change period_type to start_date in score_conditions")
// Проверяем, существует ли уже поле start_date
var exists bool
err := a.DB.QueryRow(`
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'score_conditions'
AND column_name = 'start_date'
)
`).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check if start_date exists: %w", err)
}
if exists {
log.Printf("Migration 020 already applied (start_date column exists), skipping")
return nil
}
// Читаем SQL файл миграции
migrationPath := "/migrations/020_change_period_to_start_date.sql"
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
// Пробуем альтернативный путь (для локальной разработки)
migrationPath = "play-life-backend/migrations/020_change_period_to_start_date.sql"
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
migrationPath = "migrations/020_change_period_to_start_date.sql"
}
}
migrationSQL, err := os.ReadFile(migrationPath)
if err != nil {
return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err)
}
// Выполняем миграцию
if _, err := a.DB.Exec(string(migrationSQL)); err != nil {
return fmt.Errorf("failed to execute migration 020: %w", err)
}
log.Printf("Migration 020 applied successfully")
return nil
}
func (a *App) initPlayLifeDB() error {
// Создаем таблицу projects
createProjectsTable := `
CREATE TABLE IF NOT EXISTS projects (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
priority SMALLINT,
CONSTRAINT unique_project_name UNIQUE (name)
)
`
// Создаем таблицу entries
createEntriesTable := `
CREATE TABLE IF NOT EXISTS entries (
id SERIAL PRIMARY KEY,
text TEXT NOT NULL,
created_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`
// Создаем таблицу nodes
createNodesTable := `
CREATE TABLE IF NOT EXISTS nodes (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
score NUMERIC(8,4)
)
`
// Создаем индексы для nodes
createNodesIndexes := []string{
`CREATE INDEX IF NOT EXISTS idx_nodes_project_id ON nodes(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_nodes_entry_id ON nodes(entry_id)`,
}
// Создаем таблицу weekly_goals
createWeeklyGoalsTable := `
CREATE TABLE IF NOT EXISTS weekly_goals (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
goal_year INTEGER NOT NULL,
goal_week INTEGER NOT NULL,
min_goal_score NUMERIC(10,4) NOT NULL DEFAULT 0,
max_goal_score NUMERIC(10,4),
actual_score NUMERIC(10,4) DEFAULT 0,
priority SMALLINT,
CONSTRAINT weekly_goals_project_id_goal_year_goal_week_key UNIQUE (project_id, goal_year, goal_week)
)
`
// Создаем индекс для weekly_goals
createWeeklyGoalsIndex := `
CREATE INDEX IF NOT EXISTS idx_weekly_goals_project_id ON weekly_goals(project_id)
`
// Выполняем создание таблиц
if _, err := a.DB.Exec(createProjectsTable); err != nil {
return fmt.Errorf("failed to create projects table: %w", err)
}
// Добавляем колонку deleted, если её нет (для существующих баз)
alterProjectsTable := `
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE
`
if _, err := a.DB.Exec(alterProjectsTable); err != nil {
log.Printf("Warning: Failed to add deleted column to projects table: %v", err)
}
// Создаем индекс на deleted
createProjectsDeletedIndex := `
CREATE INDEX IF NOT EXISTS idx_projects_deleted ON projects(deleted)
`
if _, err := a.DB.Exec(createProjectsDeletedIndex); err != nil {
log.Printf("Warning: Failed to create projects deleted index: %v", err)
}
if _, err := a.DB.Exec(createEntriesTable); err != nil {
return fmt.Errorf("failed to create entries table: %w", err)
}
if _, err := a.DB.Exec(createNodesTable); err != nil {
return fmt.Errorf("failed to create nodes table: %w", err)
}
for _, indexSQL := range createNodesIndexes {
if _, err := a.DB.Exec(indexSQL); err != nil {
log.Printf("Warning: Failed to create index: %v", err)
}
}
if _, err := a.DB.Exec(createWeeklyGoalsTable); err != nil {
return fmt.Errorf("failed to create weekly_goals table: %w", err)
}
if _, err := a.DB.Exec(createWeeklyGoalsIndex); err != nil {
log.Printf("Warning: Failed to create weekly_goals index: %v", err)
}
// Создаем materialized view (может потребоваться удаление старого, если он существует)
dropMaterializedView := `DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv`
a.DB.Exec(dropMaterializedView) // Игнорируем ошибку, если view не существует
createMaterializedView := `
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
JOIN
entries e ON n.entry_id = e.id
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
`
if _, err := a.DB.Exec(createMaterializedView); err != nil {
return fmt.Errorf("failed to create weekly_report_mv: %w", err)
}
// Создаем индекс для materialized view
createMVIndex := `
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week)
`
if _, err := a.DB.Exec(createMVIndex); err != nil {
log.Printf("Warning: Failed to create materialized view index: %v", err)
}
// Создаем таблицу telegram_integrations
createTelegramIntegrationsTable := `
CREATE TABLE IF NOT EXISTS telegram_integrations (
id SERIAL PRIMARY KEY,
chat_id VARCHAR(255),
bot_token VARCHAR(255)
)
`
if _, err := a.DB.Exec(createTelegramIntegrationsTable); err != nil {
return fmt.Errorf("failed to create telegram_integrations table: %w", err)
}
// Создаем таблицу tasks
createTasksTable := `
CREATE TABLE IF NOT EXISTS tasks (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
completed INTEGER DEFAULT 0,
last_completed_at TIMESTAMP WITH TIME ZONE,
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
reward_message TEXT,
progression_base NUMERIC(10,4),
deleted BOOLEAN DEFAULT FALSE
)
`
if _, err := a.DB.Exec(createTasksTable); err != nil {
return fmt.Errorf("failed to create tasks table: %w", err)
}
// Создаем индексы для tasks
createTasksIndexes := []string{
`CREATE INDEX IF NOT EXISTS idx_tasks_user_id ON tasks(user_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_parent_task_id ON tasks(parent_task_id)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks(deleted)`,
`CREATE INDEX IF NOT EXISTS idx_tasks_last_completed_at ON tasks(last_completed_at)`,
}
for _, indexSQL := range createTasksIndexes {
if _, err := a.DB.Exec(indexSQL); err != nil {
log.Printf("Warning: Failed to create tasks index: %v", err)
}
}
// Apply migration 016: Add repetition_period to tasks
if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS repetition_period INTERVAL"); err != nil {
log.Printf("Warning: Failed to apply migration 016 (add repetition_period): %v", err)
}
// Apply migration 017: Add next_show_at to tasks
if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS next_show_at TIMESTAMP WITH TIME ZONE"); err != nil {
log.Printf("Warning: Failed to apply migration 017 (add next_show_at): %v", err)
}
// Apply migration 018: Add repetition_date to tasks
if _, err := a.DB.Exec("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS repetition_date TEXT"); err != nil {
log.Printf("Warning: Failed to apply migration 018 (add repetition_date): %v", err)
}
// Создаем таблицу reward_configs
createRewardConfigsTable := `
CREATE TABLE IF NOT EXISTS reward_configs (
id SERIAL PRIMARY KEY,
position INTEGER NOT NULL,
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE,
value NUMERIC(10,4) NOT NULL,
use_progression BOOLEAN DEFAULT FALSE
)
`
if _, err := a.DB.Exec(createRewardConfigsTable); err != nil {
return fmt.Errorf("failed to create reward_configs table: %w", err)
}
// Создаем индексы для reward_configs
createRewardConfigsIndexes := []string{
`CREATE INDEX IF NOT EXISTS idx_reward_configs_task_id ON reward_configs(task_id)`,
`CREATE INDEX IF NOT EXISTS idx_reward_configs_project_id ON reward_configs(project_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_reward_configs_task_position ON reward_configs(task_id, position)`,
}
for _, indexSQL := range createRewardConfigsIndexes {
if _, err := a.DB.Exec(indexSQL); err != nil {
log.Printf("Warning: Failed to create reward_configs index: %v", err)
}
}
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: Setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
if err := a.setupWeeklyGoals(); err != nil {
log.Printf("Error in scheduled weekly goals setup: %v", err)
}
})
if err != nil {
log.Printf("Error adding cron job for weekly goals: %v", err)
return
}
// Запускаем планировщик
c.Start()
log.Printf("Weekly goals scheduler started: every Monday at 6:00 AM %s", timezoneStr)
// Планировщик будет работать в фоновом режиме
}
// getWeeklyStatsData получает данные о проектах и их целях (без HTTP обработки)
func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
// Обновляем materialized view перед запросом
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
// Продолжаем выполнение даже если обновление не удалось
}
query := `
SELECT
p.name AS project_name,
-- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv
COALESCE(wr.total_score, 0.0000) AS total_score,
wg.min_goal_score,
wg.max_goal_score,
wg.priority AS priority
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 minGoalScore sql.NullFloat64
var maxGoalScore sql.NullFloat64
var priority sql.NullInt64
err := rows.Scan(
&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)
}
if minGoalScore.Valid {
project.MinGoalScore = minGoalScore.Float64
} else {
project.MinGoalScore = 0
}
if maxGoalScore.Valid {
maxGoalVal := maxGoalScore.Float64
project.MaxGoalScore = &maxGoalVal
}
var priorityVal int
if priority.Valid {
priorityVal = int(priority.Int64)
project.Priority = &priorityVal
}
// Расчет calculated_score по формуле из n8n
totalScore := project.TotalScore
minGoalScoreVal := project.MinGoalScore
var maxGoalScoreVal float64
if project.MaxGoalScore != nil {
maxGoalScoreVal = *project.MaxGoalScore
}
// Параметры бонуса в зависимости от priority
var extraBonusLimit float64 = 20
if priorityVal == 1 {
extraBonusLimit = 50
} else if priorityVal == 2 {
extraBonusLimit = 35
}
// Расчет базового прогресса
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)
}
// Вычисляем общий процент выполнения
total := calculateOverallProgress(groups)
response := WeeklyStatsResponse{
Total: total,
Projects: projects,
}
return &response, nil
}
// getWeeklyStatsDataForUser получает данные о проектах для конкретного пользователя
func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error) {
// Обновляем materialized view перед запросом
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
}
query := `
SELECT
p.name AS project_name,
COALESCE(wr.total_score, 0.0000) AS total_score,
wg.min_goal_score,
wg.max_goal_score,
wg.priority AS priority
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 minGoalScore sql.NullFloat64
var maxGoalScore sql.NullFloat64
var priority sql.NullInt64
err := rows.Scan(
&project.ProjectName,
&project.TotalScore,
&minGoalScore,
&maxGoalScore,
&priority,
)
if err != nil {
return nil, fmt.Errorf("error scanning weekly stats row: %w", err)
}
if minGoalScore.Valid {
project.MinGoalScore = minGoalScore.Float64
} else {
project.MinGoalScore = 0
}
if maxGoalScore.Valid {
maxGoalVal := maxGoalScore.Float64
project.MaxGoalScore = &maxGoalVal
}
var priorityVal int
if priority.Valid {
priorityVal = int(priority.Int64)
project.Priority = &priorityVal
}
// Расчет calculated_score
totalScore := project.TotalScore
minGoalScoreVal := project.MinGoalScore
var maxGoalScoreVal float64
if project.MaxGoalScore != nil {
maxGoalScoreVal = *project.MaxGoalScore
}
// Параметры бонуса в зависимости от priority
var extraBonusLimit float64 = 20
if priorityVal == 1 {
extraBonusLimit = 50
} else if priorityVal == 2 {
extraBonusLimit = 35
}
// Расчет базового прогресса
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)
}
}
// Вычисляем общий процент выполнения
total := calculateOverallProgress(groups)
response := WeeklyStatsResponse{
Total: total,
Projects: projects,
}
return &response, nil
}
// formatDailyReport форматирует данные проектов в сообщение для Telegram
// Формат аналогичен JS коду из n8n
func (a *App) formatDailyReport(data *WeeklyStatsResponse) string {
if data == nil || len(data.Projects) == 0 {
return ""
}
// Заголовок сообщения
markdownMessage := "*📈 Отчет по Score и Целям за текущую неделю:*\n\n"
// Простой вывод списка проектов
for _, item := range data.Projects {
projectName := item.ProjectName
if projectName == "" {
projectName = "Без названия"
}
actualScore := item.TotalScore
minGoal := item.MinGoalScore
var maxGoal float64
hasMaxGoal := false
if item.MaxGoalScore != nil {
maxGoal = *item.MaxGoalScore
hasMaxGoal = true
}
// Форматирование Score (+/-)
scoreFormatted := ""
if actualScore >= 0 {
scoreFormatted = fmt.Sprintf("+%.2f", actualScore)
} else {
scoreFormatted = fmt.Sprintf("%.2f", actualScore)
}
// Форматирование текста целей
// Проверяем, что minGoal валиден (не NaN, как в JS коде: !isNaN(minGoal))
goalText := ""
if !math.IsNaN(minGoal) {
if hasMaxGoal && !math.IsNaN(maxGoal) {
goalText = fmt.Sprintf(" (Цель: %.1f%.1f)", minGoal, maxGoal)
} else {
goalText = fmt.Sprintf(" (Цель: мин. %.1f)", minGoal)
}
}
// Собираем строку: Проект: +Score (Цели)
markdownMessage += fmt.Sprintf("*%s*: %s%s\n", projectName, scoreFormatted, goalText)
}
// Выводим итоговый total из корня JSON
if data.Total != nil {
markdownMessage += "\n---\n"
markdownMessage += fmt.Sprintf("*Общее выполнение целей*: %.1f%%", *data.Total)
}
return markdownMessage
}
// sendDailyReport отправляет персональные ежедневные отчеты всем пользователям
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)
// Планировщик будет работать в фоновом режиме
}
// 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")
}
// Инициализируем БД для play-life проекта
if err := app.initPlayLifeDB(); err != nil {
log.Fatal("Failed to initialize play-life database:", err)
}
log.Println("Play-life database initialized successfully")
// Инициализируем БД для слов, словарей и конфигураций
if err := app.initDB(); err != nil {
log.Fatal("Failed to initialize words/dictionaries database:", err)
}
log.Println("Words/dictionaries database initialized successfully")
// Инициализируем таблицы для авторизации
if err := app.initAuthDB(); err != nil {
log.Fatal("Failed to initialize auth database:", err)
}
log.Println("Auth database initialized successfully")
// Запускаем планировщик для автоматической фиксации целей на неделю
app.startWeeklyGoalsScheduler()
// Запускаем планировщик для ежедневного отчета в 23:59
app.startDailyReportScheduler()
r := mux.NewRouter()
// Public auth routes (no authentication required)
r.HandleFunc("/api/auth/register", app.registerHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/api/auth/login", app.loginHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/api/auth/refresh", app.refreshTokenHandler).Methods("POST", "OPTIONS")
// Webhooks - no auth (external services)
r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS")
// Admin pages (basic access, consider adding auth later)
r.HandleFunc("/admin", app.adminHandler).Methods("GET")
r.HandleFunc("/admin.html", app.adminHandler).Methods("GET")
// Static files handler для uploads (public, no auth required) - ДО protected!
// Backend работает из /app/backend/, но uploads находится в /app/uploads/
r.HandleFunc("/uploads/{path:.*}", func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
path := vars["path"]
filePath := filepath.Join("/app/uploads", path)
// Проверяем, что файл существует
if _, err := os.Stat(filePath); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
// Отдаём файл
http.ServeFile(w, r, filePath)
}).Methods("GET")
// Protected routes (require authentication)
protected := r.PathPrefix("/").Subrouter()
protected.Use(app.authMiddleware)
// Auth routes that need authentication
protected.HandleFunc("/api/auth/logout", app.logoutHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/auth/me", app.getMeHandler).Methods("GET", "OPTIONS")
// Words & dictionaries
protected.HandleFunc("/api/words", app.getWordsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/words", app.addWordsHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/test/words", app.getTestWordsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/test/progress", app.updateTestProgressHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/dictionaries", app.getDictionariesHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/dictionaries", app.addDictionaryHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/dictionaries/{id}", app.updateDictionaryHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/dictionaries/{id}", app.deleteDictionaryHandler).Methods("DELETE", "OPTIONS")
// Configs
protected.HandleFunc("/api/configs", app.getConfigsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/configs", app.addConfigHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/configs/{id}", app.updateConfigHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/configs/{id}", app.deleteConfigHandler).Methods("DELETE", "OPTIONS")
protected.HandleFunc("/api/configs/{id}/dictionaries", app.getConfigDictionariesHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/test-configs-and-dictionaries", app.getTestConfigsAndDictionariesHandler).Methods("GET", "OPTIONS")
// Projects & stats
protected.HandleFunc("/api/weekly-stats", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/playlife-feed", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/message/post", app.messagePostHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/weekly_goals/setup", app.weeklyGoalsSetupHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/project/create", app.createProjectHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS")
// Integrations
protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "OPTIONS")
// Todoist OAuth endpoints
protected.HandleFunc("/api/integrations/todoist/oauth/connect", app.todoistOAuthConnectHandler).Methods("GET")
r.HandleFunc("/api/integrations/todoist/oauth/callback", app.todoistOAuthCallbackHandler).Methods("GET") // Публичный!
protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS")
// Tasks
protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS")
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")
protected.HandleFunc("/api/tasks/{id}/complete", app.completeTaskHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/tasks/{id}/complete-and-delete", app.completeAndDeleteTaskHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/tasks/{id}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS")
// Wishlist
protected.HandleFunc("/api/wishlist", app.getWishlistHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}", app.deleteWishlistHandler).Methods("DELETE", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}/image", app.uploadWishlistImageHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}/complete", app.completeWishlistHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS")
// 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
}
// calculateOverallProgress вычисляет общий процент выполнения на основе групп проектов по приоритетам
// groups - карта приоритетов к спискам calculatedScore проектов
// Возвращает указатель на float64 с общим процентом выполнения или nil, если нет данных
// Если какая-то группа отсутствует, она считается как 100%
func calculateOverallProgress(groups map[int][]float64) *float64 {
// Всего есть 3 группы: приоритет 1, приоритет 2, приоритет 0
// Вычисляем среднее для каждой группы, если она есть
// Если группы нет, считаем её как 100%
groupAverages := make(map[int]float64)
// Обрабатываем все 3 возможных приоритета
priorities := []int{1, 2, 0}
for _, priorityVal := range priorities {
scores, exists := groups[priorityVal]
if !exists || len(scores) == 0 {
// Если группы нет, считаем как 100%
groupAverages[priorityVal] = 100.0
} else {
// Вычисляем среднее для группы
var avg float64
// Для приоритета 1 и 2 - обычное среднее
if priorityVal == 1 || priorityVal == 2 {
sum := 0.0
for _, score := range scores {
sum += 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 {
// score уже в процентах (например, 80.0), переводим в долю (0.8)
scoreAsDecimal := score / 100.0
sum += scoreAsDecimal * multiplier
}
avg = math.Min(120.0, sum)
}
groupAverages[priorityVal] = avg
}
}
// Находим среднее между всеми тремя группами
sum := 0.0
for _, priorityVal := range priorities {
sum += groupAverages[priorityVal]
}
overallProgress := sum / 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 {
// Проект не существует, создаем новый
_, err = tx.Exec(`
INSERT INTO projects (name, deleted, user_id)
VALUES ($1, FALSE, $2)
`, projectName, *userID)
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 {
// Проект не существует, создаем новый
_, err = tx.Exec(`
INSERT INTO projects (name, deleted)
VALUES ($1, FALSE)
`, projectName)
if err != nil {
return fmt.Errorf("failed to insert project %s: %w", projectName, err)
}
} else if err != nil {
return fmt.Errorf("failed to check project %s: %w", projectName, err)
}
// Проект уже существует, ничего не делаем
}
}
// 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
if userID != nil {
_, err = tx.Exec(`
INSERT INTO nodes (project_id, entry_id, score, user_id)
VALUES ($1, $2, $3, $4)
`, projectID, entryID, node.Score, *userID)
} else {
_, err = tx.Exec(`
INSERT INTO nodes (project_id, entry_id, score)
VALUES ($1, $2, $3)
`, projectID, entryID, node.Score)
}
if err != nil {
return fmt.Errorf("failed to insert node for project %s: %w", node.Project, err)
}
}
// Обновляем materialized view после вставки данных
_, err = tx.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
// Не возвращаем ошибку, так как это не критично
}
// Коммитим транзакцию
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// setupWeeklyGoals выполняет установку целей на неделю (без HTTP обработки)
func (a *App) setupWeeklyGoals() error {
// 1. Выполняем SQL запрос для установки целей
setupQuery := `
WITH current_info AS (
-- Сегодня это будет 2026 год / 1 неделя
SELECT
EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AS c_year,
EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER AS c_week
),
goal_metrics AS (
-- Считаем медиану на основе данных за 3 месяца (12 недель), исключая текущую неделю
SELECT
project_id,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_score) AS median_score
FROM (
SELECT
project_id,
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 <= 12 -- Берем историю за последние 12 недель (3 месяца), исключая текущую неделю
GROUP BY project_id
)
INSERT INTO weekly_goals (
project_id,
goal_year,
goal_week,
min_goal_score,
max_goal_score,
priority
)
SELECT
p.id,
ci.c_year,
ci.c_week,
-- Если нет данных (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 * 1.5
WHEN p.priority = 2 THEN gm.median_score * 1.3
ELSE gm.median_score * 1.2
END AS max_goal_score,
p.priority
FROM projects p
CROSS JOIN current_info ci
LEFT JOIN goal_metrics gm ON p.id = gm.project_id
WHERE p.deleted = FALSE
ON CONFLICT (project_id, goal_year, goal_week) DO UPDATE
SET
min_goal_score = EXCLUDED.min_goal_score,
max_goal_score = EXCLUDED.max_goal_score,
priority = EXCLUDED.priority
`
_, err := a.DB.Exec(setupQuery)
if err != nil {
log.Printf("Error setting up weekly goals: %v", err)
return fmt.Errorf("error setting up weekly goals: %w", err)
}
log.Println("Weekly goals setup completed successfully")
// Отправляем сообщение в Telegram с зафиксированными целями
if err := a.sendWeeklyGoalsTelegramMessage(); err != nil {
log.Printf("Error sending weekly goals Telegram message: %v", err)
// Не возвращаем ошибку, так как фиксация целей уже выполнена успешно
}
return nil
}
// 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
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
JOIN
entries e ON n.entry_id = e.id
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
WHERE
p.deleted = FALSE
ORDER BY
p.id, agg.report_year, agg.report_week
`
if _, err := a.DB.Exec(createMaterializedView); err != nil {
log.Printf("Error creating materialized view: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating materialized view: %v", err), http.StatusInternalServerError)
return
}
// Создаем индекс
createMVIndex := `
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week)
`
if _, err := a.DB.Exec(createMVIndex); err != nil {
log.Printf("Warning: Failed to create materialized view index: %v", err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Materialized view recreated successfully with ISOYEAR fix",
})
}
func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
query := `
SELECT
id AS project_id,
name AS project_name,
priority
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,
)
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 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
}
// Теперь обновляем оставшиеся записи (те, которые не конфликтуют)
_, err = tx.Exec(`
UPDATE weekly_goals
SET project_id = $1
WHERE project_id = $2
`, finalProjectID, req.ID)
if err != nil {
log.Printf("Error updating weekly_goals: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error updating weekly_goals: %v", err), http.StatusInternalServerError)
return
}
// Помечаем старый проект как удаленный
_, err = tx.Exec(`
UPDATE projects
SET deleted = TRUE
WHERE id = $1
`, req.ID)
if err != nil {
log.Printf("Error marking project as deleted: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error marking project as deleted: %v", err), http.StatusInternalServerError)
return
}
// Коммитим транзакцию
if err := tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
return
}
// Обновляем materialized view
_, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Project moved successfully",
"project_id": finalProjectID,
})
}
func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
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
}
// Обновляем materialized view
_, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Project deleted successfully",
})
}
func (a *App) createProjectHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
var req ProjectCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Error decoding create project request: %v", err)
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Name == "" {
sendErrorWithCORS(w, "name is required", http.StatusBadRequest)
return
}
// Проверяем, существует ли уже проект с таким именем
var existingID int
err := a.DB.QueryRow(`
SELECT id FROM projects
WHERE name = $1 AND user_id = $2 AND deleted = FALSE
`, req.Name, userID).Scan(&existingID)
if err == nil {
// Проект уже существует
sendErrorWithCORS(w, "Project with this name already exists", http.StatusConflict)
return
} else if err != sql.ErrNoRows {
log.Printf("Error checking project existence: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error checking project existence: %v", err), http.StatusInternalServerError)
return
}
// Создаем новый проект
var projectID int
err = a.DB.QueryRow(`
INSERT INTO projects (name, deleted, user_id)
VALUES ($1, FALSE, $2)
RETURNING id
`, req.Name, userID).Scan(&projectID)
if err != nil {
log.Printf("Error creating project: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating project: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"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
}
query := `
SELECT
p.name AS project_name,
-- Определяем год и неделю, беря значение из той таблицы, где оно не NULL
COALESCE(wr.report_year, wg.goal_year) AS report_year,
COALESCE(wr.report_week, wg.goal_week) AS report_week,
-- Фактический score: COALESCE(NULL, 0.0000)
COALESCE(wr.total_score, 0.0000) AS total_score,
-- Минимальная цель: COALESCE(NULL, 0.0000)
COALESCE(wg.min_goal_score, 0.0000) AS min_goal_score,
-- Максимальная цель: COALESCE(NULL, 0.0000)
COALESCE(wg.max_goal_score, 0.0000) AS max_goal_score
FROM
weekly_report_mv wr
FULL OUTER JOIN
weekly_goals wg
-- Слияние по всем трем ключевым полям
ON wr.project_id = wg.project_id
AND wr.report_year = wg.goal_year
AND wr.report_week = wg.goal_week
JOIN
projects p
-- Присоединяем имя проекта, используя ID из той таблицы, где он не NULL
ON p.id = COALESCE(wr.project_id, wg.project_id)
WHERE
p.deleted = FALSE 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
err := rows.Scan(
&item.ProjectName,
&item.ReportYear,
&item.ReportWeek,
&item.TotalScore,
&item.MinGoalScore,
&item.MaxGoalScore,
)
if err != nil {
log.Printf("Error scanning full statistics row: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError)
return
}
statistics = append(statistics, item)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(statistics)
}
// 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,
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
FROM tasks t
WHERE t.user_id = $1 AND t.parent_task_id IS NULL AND t.deleted = FALSE
ORDER BY
CASE WHEN t.last_completed_at IS NULL OR t.last_completed_at::date < CURRENT_DATE THEN 0 ELSE 1 END,
t.name
`
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 projectNames pq.StringArray
var subtaskProjectNames pq.StringArray
err := rows.Scan(
&task.ID,
&task.Name,
&task.Completed,
&lastCompletedAt,
&nextShowAt,
&repetitionPeriod,
&repetitionDate,
&progressionBase,
&task.SubtasksCount,
&projectNames,
&subtaskProjectNames,
)
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
}
// Объединяем проекты из основной задачи и подзадач
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
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с 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
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,
)
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)
}
// Получаем награды основной задачи
rewards := make([]Reward, 0)
rewardRows, err := a.DB.Query(`
SELECT rc.id, rc.position, p.name AS project_name, rc.value, rc.use_progression
FROM reward_configs rc
JOIN projects p ON rc.project_id = p.id
WHERE rc.task_id = $1
ORDER BY rc.position
`, taskID)
if err != nil {
log.Printf("Error querying rewards: %v", err)
} else {
defer rewardRows.Close()
for rewardRows.Next() {
var reward Reward
err := rewardRows.Scan(&reward.ID, &reward.Position, &reward.ProjectName, &reward.Value, &reward.UseProgression)
if err != nil {
log.Printf("Error scanning reward: %v", err)
continue
}
rewards = append(rewards, reward)
}
}
// Получаем подзадачи
subtasks := make([]Subtask, 0)
subtaskRows, err := a.DB.Query(`
SELECT id, name, completed, last_completed_at, reward_message, progression_base
FROM tasks
WHERE parent_task_id = $1 AND deleted = FALSE
ORDER BY id
`, taskID)
if err != nil {
log.Printf("Error querying subtasks: %v", err)
} else {
defer subtaskRows.Close()
for subtaskRows.Next() {
var subtaskTask Task
var subtaskRewardMessage sql.NullString
var subtaskProgressionBase sql.NullFloat64
var subtaskLastCompletedAt sql.NullString
err := subtaskRows.Scan(
&subtaskTask.ID, &subtaskTask.Name, &subtaskTask.Completed,
&subtaskLastCompletedAt, &subtaskRewardMessage, &subtaskProgressionBase,
)
if err != nil {
log.Printf("Error scanning subtask: %v", err)
continue
}
if subtaskRewardMessage.Valid {
subtaskTask.RewardMessage = &subtaskRewardMessage.String
}
if subtaskProgressionBase.Valid {
subtaskTask.ProgressionBase = &subtaskProgressionBase.Float64
}
if subtaskLastCompletedAt.Valid {
subtaskTask.LastCompletedAt = &subtaskLastCompletedAt.String
}
// Получаем награды подзадачи
subtaskRewards := make([]Reward, 0)
subtaskRewardRows, 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
`, subtaskTask.ID)
if err == nil {
for subtaskRewardRows.Next() {
var reward Reward
err := subtaskRewardRows.Scan(&reward.ID, &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()
}
subtasks = append(subtasks, Subtask{
Task: subtaskTask,
Rewards: subtaskRewards,
})
}
}
response := TaskDetail{
Task: task,
Rewards: rewards,
Subtasks: subtasks,
}
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
}
}
// Начинаем транзакцию
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
}
// Используем условный SQL для обработки NULL значений
var insertSQL string
var insertArgs []interface{}
if repetitionPeriod.Valid {
// Для repetition_period выставляем сегодняшнюю дату
now := time.Now()
insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted)
VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now}
} else if repetitionDate.Valid {
// Вычисляем next_show_at для задачи с repetition_date
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now())
if nextShowAt != nil {
insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted)
VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt}
} else {
insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted)
VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String}
}
} else {
insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted)
VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase}
}
err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID)
if err != nil {
log.Printf("Error creating task: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating task: %v", err), http.StatusInternalServerError)
return
}
// Создаем награды для основной задачи
for _, rewardReq := range req.Rewards {
projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID)
if err != nil {
log.Printf("Error finding project %s: %v", rewardReq.ProjectName, err)
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
_, err = tx.Exec(`
INSERT INTO reward_configs (position, task_id, project_id, value, use_progression)
VALUES ($1, $2, $3, $4, $5)
`, rewardReq.Position, taskID, projectID, rewardReq.Value, rewardReq.UseProgression)
if err != nil {
log.Printf("Error creating reward: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating reward: %v", err), http.StatusInternalServerError)
return
}
}
// Создаем подзадачи
for _, subtaskReq := range req.Subtasks {
var subtaskName sql.NullString
var subtaskRewardMessage sql.NullString
var subtaskProgressionBase sql.NullFloat64
if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" {
subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true}
}
if subtaskReq.RewardMessage != nil {
subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true}
}
if req.ProgressionBase != nil {
subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true}
}
var subtaskID int
err = tx.QueryRow(`
INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted)
VALUES ($1, $2, $3, $4, $5, 0, FALSE)
RETURNING id
`, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase).Scan(&subtaskID)
if err != nil {
log.Printf("Error creating subtask: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask: %v", err), http.StatusInternalServerError)
return
}
// Создаем награды для подзадачи
for _, rewardReq := range subtaskReq.Rewards {
if strings.TrimSpace(rewardReq.ProjectName) == "" {
sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest)
return
}
projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID)
if err != nil {
log.Printf("Error finding project %s for subtask: %v", rewardReq.ProjectName, err)
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
_, err = tx.Exec(`
INSERT INTO reward_configs (position, task_id, project_id, value, use_progression)
VALUES ($1, $2, $3, $4, $5)
`, rewardReq.Position, subtaskID, projectID, rewardReq.Value, rewardReq.UseProgression)
if err != nil {
log.Printf("Error creating subtask reward: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError)
return
}
}
}
// Коммитим транзакцию
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
}
}
// Начинаем транзакцию
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)
}
// Используем условный SQL для обработки NULL значений
var updateSQL string
var updateArgs []interface{}
if repetitionPeriod.Valid {
// Для repetition_period выставляем сегодняшнюю дату
now := time.Now()
updateSQL = `
UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, next_show_at = $5
WHERE id = $6
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, taskID}
} else if repetitionDate.Valid {
// Вычисляем next_show_at для задачи с repetition_date
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now())
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
WHERE id = $6
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, taskID}
} else {
updateSQL = `
UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4
WHERE id = $5
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, taskID}
}
} else {
updateSQL = `
UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = NULL, next_show_at = NULL
WHERE id = $4
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, 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 _, subtaskReq := range req.Subtasks {
if subtaskReq.ID != nil {
subtaskIDsInRequest[*subtaskReq.ID] = true
// Обновляем существующую подзадачу
var subtaskName sql.NullString
var subtaskRewardMessage sql.NullString
var subtaskProgressionBase sql.NullFloat64
if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" {
subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true}
}
if subtaskReq.RewardMessage != nil {
subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true}
}
if req.ProgressionBase != nil {
subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true}
}
_, err = tx.Exec(`
UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3
WHERE id = $4 AND parent_task_id = $5
`, subtaskName, subtaskRewardMessage, subtaskProgressionBase, *subtaskReq.ID, taskID)
if err != nil {
log.Printf("Error updating subtask: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error updating subtask: %v", err), http.StatusInternalServerError)
return
}
// Удаляем старые награды подзадачи
_, err = tx.Exec("DELETE FROM reward_configs WHERE task_id = $1", *subtaskReq.ID)
if err != nil {
log.Printf("Error deleting old subtask rewards: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error deleting old subtask rewards: %v", err), http.StatusInternalServerError)
return
}
// Вставляем новые награды подзадачи
for _, rewardReq := range subtaskReq.Rewards {
if strings.TrimSpace(rewardReq.ProjectName) == "" {
sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest)
return
}
projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID)
if err != nil {
log.Printf("Error finding project %s for subtask: %v", rewardReq.ProjectName, err)
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
_, err = tx.Exec(`
INSERT INTO reward_configs (position, task_id, project_id, value, use_progression)
VALUES ($1, $2, $3, $4, $5)
`, rewardReq.Position, *subtaskReq.ID, projectID, rewardReq.Value, rewardReq.UseProgression)
if err != nil {
log.Printf("Error creating subtask reward: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError)
return
}
}
} else {
// Создаем новую подзадачу
var subtaskName sql.NullString
var subtaskRewardMessage sql.NullString
var subtaskProgressionBase sql.NullFloat64
if subtaskReq.Name != nil && strings.TrimSpace(*subtaskReq.Name) != "" {
subtaskName = sql.NullString{String: strings.TrimSpace(*subtaskReq.Name), Valid: true}
}
if subtaskReq.RewardMessage != nil {
subtaskRewardMessage = sql.NullString{String: *subtaskReq.RewardMessage, Valid: true}
}
if req.ProgressionBase != nil {
subtaskProgressionBase = sql.NullFloat64{Float64: *req.ProgressionBase, Valid: true}
}
var subtaskID int
err = tx.QueryRow(`
INSERT INTO tasks (user_id, name, parent_task_id, reward_message, progression_base, completed, deleted)
VALUES ($1, $2, $3, $4, $5, 0, FALSE)
RETURNING id
`, userID, subtaskName, taskID, subtaskRewardMessage, subtaskProgressionBase).Scan(&subtaskID)
if err != nil {
log.Printf("Error creating subtask: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask: %v", err), http.StatusInternalServerError)
return
}
// Создаем награды для новой подзадачи
for _, rewardReq := range subtaskReq.Rewards {
if strings.TrimSpace(rewardReq.ProjectName) == "" {
sendErrorWithCORS(w, "Project name is required for all rewards", http.StatusBadRequest)
return
}
projectID, err := a.findProjectByNameTx(tx, rewardReq.ProjectName, userID)
if err != nil {
log.Printf("Error finding project %s for new subtask: %v", rewardReq.ProjectName, err)
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
_, err = tx.Exec(`
INSERT INTO reward_configs (position, task_id, project_id, value, use_progression)
VALUES ($1, $2, $3, $4, $5)
`, rewardReq.Position, subtaskID, projectID, rewardReq.Value, rewardReq.UseProgression)
if err != nil {
log.Printf("Error creating subtask reward: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating subtask reward: %v", err), http.StatusInternalServerError)
return
}
}
}
}
// Помечаем подзадачи, которые были в БД, но не пришли в запросе, как deleted
for subtaskID := range currentSubtaskIDs {
if !subtaskIDsInRequest[subtaskID] {
_, err = tx.Exec("UPDATE tasks SET deleted = TRUE WHERE id = $1", subtaskID)
if err != nil {
log.Printf("Error marking subtask as deleted: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error marking subtask as deleted: %v", err), http.StatusInternalServerError)
return
}
}
}
// Коммитим транзакцию
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)
}
// 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",
})
}
// 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
}
// Получаем задачу и проверяем владельца
var task Task
var rewardMessage sql.NullString
var progressionBase sql.NullFloat64
var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
var ownerID int
err = a.DB.QueryRow(`
SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id
FROM tasks
WHERE id = $1 AND deleted = FALSE
`, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID)
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 ownerID != userID {
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
return
}
// Валидация: если progression_base != null, то value обязателен
if progressionBase.Valid && req.Value == nil {
sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest)
return
}
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)
sendErrorWithCORS(w, fmt.Sprintf("Error querying rewards: %v", err), http.StatusInternalServerError)
return
}
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 - вычисляем следующую дату показа
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now())
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
now := time.Now()
log.Printf("Calculating next_show_at for task %d: repetition_period='%s', fromDate=%v", taskID, repetitionPeriod.String, now)
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)
sendErrorWithCORS(w, fmt.Sprintf("Error updating task completion: %v", err), http.StatusInternalServerError)
return
}
// Обновляем выбранные подзадачи
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)
// Не возвращаем ошибку, основная задача уже обновлена
}
}
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
}
// Сначала выполняем задачу (используем ту же логику, что и в completeTaskHandler)
// Создаем временный запрос для выполнения задачи
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
}
// Получаем задачу и проверяем владельца
var task Task
var rewardMessage sql.NullString
var progressionBase sql.NullFloat64
var repetitionPeriod sql.NullString
var repetitionDate sql.NullString
var ownerID int
err = a.DB.QueryRow(`
SELECT id, name, reward_message, progression_base, repetition_period::text, repetition_date, user_id
FROM tasks
WHERE id = $1 AND deleted = FALSE
`, taskID).Scan(&task.ID, &task.Name, &rewardMessage, &progressionBase, &repetitionPeriod, &repetitionDate, &ownerID)
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 ownerID != userID {
sendErrorWithCORS(w, "Task not found", http.StatusNotFound)
return
}
// Валидация: если progression_base != null, то value обязателен
if progressionBase.Valid && req.Value == nil {
sendErrorWithCORS(w, "Value is required when progression_base is set", http.StatusBadRequest)
return
}
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)
sendErrorWithCORS(w, fmt.Sprintf("Error querying rewards: %v", err), http.StatusInternalServerError)
return
}
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
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)
}
}
for i := 0; i < 100; i++ {
placeholder := fmt.Sprintf("${%d}", i)
if rewardStr, ok := rewardStrings[i]; ok {
result = strings.ReplaceAll(result, placeholder, rewardStr)
}
}
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
}
afterIdx := idx + len(searchStr)
if afterIdx >= len(result) || result[afterIdx] < '0' || result[afterIdx] > '9' {
result = result[:idx] + rewardStr + result[afterIdx:]
} else {
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 {
mainTaskMessage = task.Name
}
// Получаем выбранные подзадачи
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
}
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()
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 {
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
}
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)
}
// Обновляем выбранные подзадачи
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)
}
}
// Помечаем задачу как удаленную
_, 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 считает баллы проекта с указанной даты до текущего момента
func (a *App) calculateProjectPointsFromDate(
projectID int,
startDate sql.NullTime,
userID int,
) (float64, error) {
var totalScore float64
var err error
// Обновляем materialized view перед запросом
_, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
}
if !startDate.Valid {
// За всё время
err = a.DB.QueryRow(`
SELECT COALESCE(SUM(wr.total_score), 0)
FROM weekly_report_mv wr
JOIN projects p ON wr.project_id = p.id
WHERE wr.project_id = $1 AND p.user_id = $2
`, projectID, userID).Scan(&totalScore)
} else {
// С указанной даты до текущего момента
// Нужно найти все недели, которые попадают в диапазон от startDate до CURRENT_DATE
// Используем сравнение (year, week) >= (startDate_year, startDate_week)
err = a.DB.QueryRow(`
SELECT COALESCE(SUM(wr.total_score), 0)
FROM weekly_report_mv wr
JOIN projects p ON wr.project_id = p.id
WHERE wr.project_id = $1
AND p.user_id = $2
AND (
wr.report_year > EXTRACT(ISOYEAR FROM $3)::INTEGER
OR (wr.report_year = EXTRACT(ISOYEAR FROM $3)::INTEGER
AND wr.report_week >= EXTRACT(WEEK FROM $3)::INTEGER)
)
`, projectID, userID, startDate.Time).Scan(&totalScore)
}
if err != nil {
return 0, err
}
return totalScore, nil
}
// 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,
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 taskID sql.NullInt64
var projectID sql.NullInt64
var requiredPoints sql.NullFloat64
var startDate sql.NullTime
err := rows.Scan(
&wcID, &displayOrder,
&taskConditionID, &scoreConditionID,
&taskID, &projectID, &requiredPoints, &startDate,
)
if err != nil {
return false, err
}
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
`, taskID.Int64, userID).Scan(&completed)
if err == sql.ErrNoRows {
conditionMet = false
} 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,
userID,
)
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
}
// 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,
wc.id AS condition_id,
wc.display_order,
wc.task_condition_id,
wc.score_condition_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
LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id
LEFT JOIN projects p ON sc.project_id = p.id
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 conditionID, displayOrder sql.NullInt64
var taskConditionID, scoreConditionID 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,
&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
}
itemsMap[itemID] = item
}
// Добавляем условие, если есть
if conditionID.Valid {
condition := UnlockConditionDisplay{
ID: int(conditionID.Int64),
DisplayOrder: int(displayOrder.Int64),
}
if taskConditionID.Valid {
condition.Type = "task_completion"
if taskName.Valid {
condition.TaskName = &taskName.String
}
} else if scoreConditionID.Valid {
condition.Type = "project_points"
if projectName.Valid {
condition.ProjectName = &projectName.String
}
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
// Определяем первое заблокированное условие и количество остальных, а также рассчитываем прогресс
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 для этого условия
var taskID int
err = a.DB.QueryRow(`
SELECT tc.task_id
FROM wishlist_conditions wc
JOIN task_conditions tc ON wc.task_condition_id = tc.id
WHERE wc.id = $1
`, condition.ID).Scan(&taskID)
if err == nil {
var completed int
err = a.DB.QueryRow(`
SELECT completed FROM tasks WHERE id = $1 AND user_id = $2
`, taskID, userID).Scan(&completed)
conditionMet = err == nil && completed > 0
completedBool := conditionMet
condition.TaskCompleted = &completedBool
}
} else if condition.Type == "project_points" {
// Находим project_id и required_points для этого условия
var projectID int
var requiredPoints float64
var startDate sql.NullTime
err = a.DB.QueryRow(`
SELECT sc.project_id, sc.required_points, sc.start_date
FROM wishlist_conditions wc
JOIN score_conditions sc ON wc.score_condition_id = sc.id
WHERE wc.id = $1
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
if err == nil {
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
conditionMet = err == nil && totalScore >= requiredPoints
if err == nil {
condition.CurrentPoints = &totalScore
}
}
}
if !conditionMet {
lockedCount++
if firstLocked == nil {
firstLocked = condition
}
}
}
if firstLocked != nil {
item.FirstLockedCondition = firstLocked
item.MoreLockedConditions = lockedCount - 1
}
} else {
// Даже если желание разблокировано, рассчитываем прогресс для всех условий
for i := range item.UnlockConditions {
condition := &item.UnlockConditions[i]
if condition.Type == "task_completion" {
var taskID int
err := a.DB.QueryRow(`
SELECT tc.task_id
FROM wishlist_conditions wc
JOIN task_conditions tc ON wc.task_condition_id = tc.id
WHERE wc.id = $1
`, condition.ID).Scan(&taskID)
if err == nil {
var completed int
err = a.DB.QueryRow(`
SELECT completed FROM tasks WHERE id = $1 AND user_id = $2
`, taskID, userID).Scan(&completed)
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
err := a.DB.QueryRow(`
SELECT sc.project_id, sc.required_points, sc.start_date
FROM wishlist_conditions wc
JOIN score_conditions sc ON wc.score_condition_id = sc.id
WHERE wc.id = $1
`, condition.ID).Scan(&projectID, &requiredPoints, &startDate)
if err == nil {
totalScore, err := a.calculateProjectPointsFromDate(projectID, startDate, userID)
if err == nil {
condition.CurrentPoints = &totalScore
}
}
}
}
}
items = append(items, *item)
}
return items, nil
}
// saveWishlistConditions сохраняет условия для желания
func (a *App) saveWishlistConditions(
tx *sql.Tx,
wishlistItemID int,
conditions []UnlockConditionRequest,
) error {
// Удаляем старые условия
_, err := tx.Exec(`
DELETE FROM wishlist_conditions
WHERE wishlist_item_id = $1
`, wishlistItemID)
if err != nil {
return err
}
if len(conditions) == 0 {
return nil
}
// Подготавливаем statement для вставки условий
stmt, err := tx.Prepare(`
INSERT INTO wishlist_conditions
(wishlist_item_id, task_condition_id, score_condition_id, display_order)
VALUES ($1, $2, $3, $4)
`)
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
}
// Создаём связь
_, err = stmt.Exec(
wishlistItemID,
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
}
includeCompleted := r.URL.Query().Get("include_completed") == "true"
items, err := a.getWishlistItemsWithConditions(userID, includeCompleted)
if err != nil {
log.Printf("Error getting wishlist items: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist items: %v", err), http.StatusInternalServerError)
return
}
// Группируем и сортируем
unlocked := make([]WishlistItem, 0)
locked := make([]WishlistItem, 0)
completed := make([]WishlistItem, 0)
for _, item := range items {
if item.Completed {
completed = append(completed, item)
} else 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
}
return priceI > priceJ
})
sort.Slice(locked, func(i, j int) bool {
priceI := 0.0
priceJ := 0.0
if locked[i].Price != nil {
priceI = *locked[i].Price
}
if locked[j].Price != nil {
priceJ = *locked[j].Price
}
return priceI > priceJ
})
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
})
response := WishlistResponse{
Unlocked: unlocked,
Locked: locked,
Completed: completed,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// 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 {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
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()
var wishlistID int
err = tx.QueryRow(`
INSERT INTO wishlist_items (user_id, name, price, link, completed, deleted)
VALUES ($1, $2, $3, $4, FALSE, FALSE)
RETURNING id
`, userID, strings.TrimSpace(req.Name), req.Price, req.Link).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 {
err = a.saveWishlistConditions(tx, wishlistID, 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
}
// Получаем созданное желание с условиями
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 {
sendErrorWithCORS(w, "Created item not found", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(createdItem)
}
// 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
}
items, err := a.getWishlistItemsWithConditions(userID, true)
if err != nil {
log.Printf("Error getting wishlist item: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error getting wishlist item: %v", err), http.StatusInternalServerError)
return
}
var item *WishlistItem
for i := range items {
if items[i].ID == itemID {
item = &items[i]
break
}
}
if item == nil {
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
return
}
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)
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 ownerID int
err = a.DB.QueryRow(`
SELECT user_id FROM wishlist_items
WHERE id = $1 AND deleted = FALSE
`, itemID).Scan(&ownerID)
if err == sql.ErrNoRows || ownerID != userID {
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("Error checking wishlist ownership: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist ownership: %v", err), http.StatusInternalServerError)
return
}
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()
_, err = tx.Exec(`
UPDATE wishlist_items
SET name = $1, price = $2, link = $3, updated_at = NOW()
WHERE id = $4 AND user_id = $5
`, strings.TrimSpace(req.Name), req.Price, req.Link, itemID, userID)
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, 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
}
// Получаем обновлённое желание
items, err := a.getWishlistItemsWithConditions(userID, true)
if err != nil {
log.Printf("Error getting updated wishlist item: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error getting updated wishlist item: %v", err), http.StatusInternalServerError)
return
}
var updatedItem *WishlistItem
for i := range items {
if items[i].ID == itemID {
updatedItem = &items[i]
break
}
}
if updatedItem == nil {
sendErrorWithCORS(w, "Updated item not found", http.StatusInternalServerError)
return
}
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
}
// Проверяем владельца
var ownerID int
err = a.DB.QueryRow(`
SELECT user_id FROM wishlist_items
WHERE id = $1 AND deleted = FALSE
`, itemID).Scan(&ownerID)
if err == sql.ErrNoRows || ownerID != userID {
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("Error checking wishlist ownership: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist ownership: %v", err), http.StatusInternalServerError)
return
}
_, err = a.DB.Exec(`
UPDATE wishlist_items
SET deleted = TRUE, updated_at = NOW()
WHERE id = $1 AND user_id = $2
`, itemID, userID)
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
}
// Проверяем владельца
var ownerID int
err = a.DB.QueryRow(`
SELECT user_id FROM wishlist_items
WHERE id = $1 AND deleted = FALSE
`, wishlistID).Scan(&ownerID)
if err == sql.ErrNoRows || ownerID != userID {
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("Error checking wishlist ownership: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist ownership: %v", err), http.StatusInternalServerError)
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
}
// Сохраняем как JPEG
filename := fmt.Sprintf("%d.jpg", wishlistID)
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 AND user_id = $3
`, imagePath, wishlistID, userID)
if err != nil {
log.Printf("Error updating database: %v", err)
sendErrorWithCORS(w, "Error updating database", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"image_url": imagePath,
})
}
// completeWishlistHandler помечает желание как завершённое
func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
itemID, err := strconv.Atoi(vars["id"])
if err != nil {
sendErrorWithCORS(w, "Invalid wishlist ID", http.StatusBadRequest)
return
}
// Проверяем владельца
var ownerID int
err = a.DB.QueryRow(`
SELECT user_id FROM wishlist_items
WHERE id = $1 AND deleted = FALSE
`, itemID).Scan(&ownerID)
if err == sql.ErrNoRows || ownerID != userID {
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("Error checking wishlist ownership: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist ownership: %v", err), http.StatusInternalServerError)
return
}
_, err = a.DB.Exec(`
UPDATE wishlist_items
SET completed = TRUE, updated_at = NOW()
WHERE id = $1 AND user_id = $2
`, itemID, userID)
if err != nil {
log.Printf("Error completing wishlist item: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error completing 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 completed successfully",
})
}
// 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
}
// Проверяем владельца
var ownerID int
err = a.DB.QueryRow(`
SELECT user_id FROM wishlist_items
WHERE id = $1 AND deleted = FALSE
`, itemID).Scan(&ownerID)
if err == sql.ErrNoRows || ownerID != userID {
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
return
}
if err != nil {
log.Printf("Error checking wishlist ownership: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error checking wishlist ownership: %v", err), http.StatusInternalServerError)
return
}
_, err = a.DB.Exec(`
UPDATE wishlist_items
SET completed = FALSE, updated_at = NOW()
WHERE id = $1 AND user_id = $2
`, itemID, userID)
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",
})
}
// LinkMetadataResponse структура ответа с метаданными ссылки
type LinkMetadataResponse struct {
Title string `json:"title,omitempty"`
Image string `json:"image,omitempty"`
Price *float64 `json:"price,omitempty"`
Description string `json:"description,omitempty"`
}
// extractLinkMetadataHandler извлекает метаданные (Open Graph, Title, Image) из URL
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 {
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.URL == "" {
sendErrorWithCORS(w, "URL is required", http.StatusBadRequest)
return
}
// Валидация URL
parsedURL, err := url.Parse(req.URL)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
sendErrorWithCORS(w, "Invalid URL", http.StatusBadRequest)
return
}
// HTTP клиент с таймаутом
client := &http.Client{
Timeout: 10 * time.Second,
}
httpReq, err := http.NewRequest("GET", req.URL, nil)
if err != nil {
log.Printf("Error creating request: %v", err)
sendErrorWithCORS(w, "Error creating request", http.StatusInternalServerError)
return
}
// Устанавливаем User-Agent (некоторые сайты блокируют запросы без него)
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
httpReq.Header.Set("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7")
resp, err := client.Do(httpReq)
if err != nil {
log.Printf("Error fetching URL: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error fetching URL: %v", err), http.StatusBadRequest)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
sendErrorWithCORS(w, fmt.Sprintf("HTTP %d", resp.StatusCode), http.StatusBadRequest)
return
}
// Ограничиваем размер ответа (первые 512KB)
limitedReader := io.LimitReader(resp.Body, 512*1024)
bodyBytes, err := io.ReadAll(limitedReader)
if err != nil {
log.Printf("Error reading response: %v", err)
sendErrorWithCORS(w, "Error reading response", http.StatusInternalServerError)
return
}
body := string(bodyBytes)
metadata := &LinkMetadataResponse{}
// Извлекаем Open Graph теги
ogTitleRe := regexp.MustCompile(`<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']`)
ogTitleRe2 := regexp.MustCompile(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']`)
ogImageRe := regexp.MustCompile(`<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']`)
ogImageRe2 := regexp.MustCompile(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']`)
ogDescRe := regexp.MustCompile(`<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']`)
ogDescRe2 := regexp.MustCompile(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']`)
// og:title
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])
}
// og:image
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])
}
// og:description
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])
}
// Если нет og:title, пытаемся взять <title>
if metadata.Title == "" {
titleRe := regexp.MustCompile(`<title[^>]*>([^<]+)</title>`)
if matches := titleRe.FindStringSubmatch(body); len(matches) > 1 {
metadata.Title = strings.TrimSpace(matches[1])
}
}
// Пытаемся найти цену (Schema.org JSON-LD или типовые паттерны)
// Schema.org Product price
priceRe := regexp.MustCompile(`"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
}
}
// Нормализуем 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
}
}
// Декодируем HTML entities
metadata.Title = decodeHTMLEntities(metadata.Title)
metadata.Description = decodeHTMLEntities(metadata.Description)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(metadata)
}
// decodeHTMLEntities декодирует базовые HTML entities
func decodeHTMLEntities(s string) string {
replacements := map[string]string{
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": "\"",
"&#39;": "'",
"&apos;": "'",
"&nbsp;": " ",
"&mdash;": "—",
"&ndash;": "",
"&laquo;": "«",
"&raquo;": "»",
}
for entity, char := range replacements {
s = strings.ReplaceAll(s, entity, char)
}
return s
}