Files
play-life/play-life-backend/main.go

7984 lines
253 KiB
Go
Raw Normal View History

2025-12-29 20:01:55 +03:00
package main
import (
"bytes"
"context"
"crypto/rand"
2025-12-29 20:01:55 +03:00
"database/sql"
"encoding/base64"
2025-12-29 20:01:55 +03:00
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
2025-12-29 20:01:55 +03:00
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf16"
"github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/golang-jwt/jwt/v5"
2025-12-29 20:01:55 +03:00
"github.com/gorilla/mux"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
"github.com/lib/pq"
2025-12-29 20:01:55 +03:00
"github.com/robfig/cron/v3"
"golang.org/x/crypto/bcrypt"
2025-12-29 20:01:55 +03:00
)
type Word struct {
ID int `json:"id"`
Name string `json:"name"`
Translation string `json:"translation"`
Description string `json:"description"`
Success int `json:"success"`
Failure int `json:"failure"`
LastSuccess *string `json:"last_success_at,omitempty"`
LastFailure *string `json:"last_failure_at,omitempty"`
}
type WordRequest struct {
Name string `json:"name"`
Translation string `json:"translation"`
Description string `json:"description"`
DictionaryID *int `json:"dictionary_id,omitempty"`
}
type WordsRequest struct {
Words []WordRequest `json:"words"`
}
type TestProgressUpdate struct {
ID int `json:"id"`
Success int `json:"success"`
Failure int `json:"failure"`
LastSuccessAt *string `json:"last_success_at,omitempty"`
LastFailureAt *string `json:"last_failure_at,omitempty"`
}
type TestProgressRequest struct {
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"`
}
2025-12-29 20:01:55 +03:00
type TelegramMessage struct {
Text string `json:"text"`
Entities []TelegramEntity `json:"entities"`
Chat TelegramChat `json:"chat"`
From *TelegramUser `json:"from,omitempty"`
2025-12-29 20:01:55 +03:00
}
type TelegramWebhook struct {
Message TelegramMessage `json:"message"`
}
// TelegramUpdate - структура для Telegram webhook (обычно это Update объект)
type TelegramUpdate struct {
UpdateID int `json:"update_id"`
Message *TelegramMessage `json:"message,omitempty"`
EditedMessage *TelegramMessage `json:"edited_message,omitempty"`
2025-12-29 20:01:55 +03:00
}
// 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"`
}
// ============================================
// 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")
func calculateNextShowAtFromRepetitionPeriod(repetitionPeriod string, fromDate time.Time) *time.Time {
if repetitionPeriod == "" {
return nil
}
parts := strings.Fields(strings.TrimSpace(repetitionPeriod))
if len(parts) < 2 {
return nil
}
value, err := strconv.Atoi(parts[0])
if err != nil {
return nil
}
unit := strings.ToLower(parts[1])
// 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":
nextDate = nextDate.Add(time.Duration(value) * time.Minute)
case "hour", "hours":
nextDate = nextDate.Add(time.Duration(value) * time.Hour)
case "day", "days":
nextDate = nextDate.AddDate(0, 0, value)
case "week", "weeks":
nextDate = nextDate.AddDate(0, 0, value*7)
case "month", "months":
nextDate = nextDate.AddDate(0, value, 0)
case "year", "years":
nextDate = nextDate.AddDate(value, 0, 0)
default:
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"
2025-12-29 20:01:55 +03:00
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
2025-12-29 20:01:55 +03:00
}
func setCORSHeaders(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
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)
}
}
}
2025-12-29 20:01:55 +03:00
}
func sendErrorWithCORS(w http.ResponseWriter, message string, statusCode int) {
setCORSHeaders(w)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": message,
})
}
func (a *App) getWordsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
2025-12-29 20:01:55 +03:00
w.WriteHeader(http.StatusOK)
return
}
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
// Get dictionary_id from query parameter
dictionaryIDStr := r.URL.Query().Get("dictionary_id")
var dictionaryID *int
if dictionaryIDStr != "" {
if id, err := strconv.Atoi(dictionaryIDStr); err == nil {
dictionaryID = &id
}
}
query := `
SELECT
w.id,
w.name,
w.translation,
w.description,
COALESCE(p.success, 0) as success,
COALESCE(p.failure, 0) as failure,
CASE WHEN p.last_success_at IS NOT NULL THEN p.last_success_at::text ELSE NULL END as last_success_at,
CASE WHEN p.last_failure_at IS NOT NULL THEN p.last_failure_at::text ELSE NULL END as last_failure_at
FROM words w
JOIN dictionaries d ON w.dictionary_id = d.id
LEFT JOIN progress p ON w.id = p.word_id AND p.user_id = $1
WHERE d.user_id = $1 AND ($2::INTEGER IS NULL OR w.dictionary_id = $2)
2025-12-29 20:01:55 +03:00
ORDER BY w.id
`
rows, err := a.DB.Query(query, userID, dictionaryID)
2025-12-29 20:01:55 +03:00
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
2025-12-29 20:01:55 +03:00
return
}
defer rows.Close()
words := make([]Word, 0)
for rows.Next() {
var word Word
var lastSuccess, lastFailure sql.NullString
err := rows.Scan(
&word.ID,
&word.Name,
&word.Translation,
&word.Description,
&word.Success,
&word.Failure,
&lastSuccess,
&lastFailure,
)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
2025-12-29 20:01:55 +03:00
return
}
if lastSuccess.Valid {
word.LastSuccess = &lastSuccess.String
}
if lastFailure.Valid {
word.LastFailure = &lastFailure.String
}
words = append(words, word)
}
setCORSHeaders(w)
2025-12-29 20:01:55 +03:00
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(words)
}
func (a *App) addWordsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
2025-12-29 20:01:55 +03:00
w.WriteHeader(http.StatusOK)
return
}
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
var req WordsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Error decoding addWords request: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
log.Printf("addWords: user_id=%d, words_count=%d", userID, len(req.Words))
2025-12-29 20:01:55 +03:00
tx, err := a.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
2025-12-29 20:01:55 +03:00
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)
}
2025-12-29 20:01:55 +03:00
stmt, err := tx.Prepare(`
INSERT INTO words (name, translation, description, dictionary_id, user_id)
VALUES ($1, $2, $3, $4, $5)
2025-12-29 20:01:55 +03:00
RETURNING id
`)
if err != nil {
log.Printf("Error preparing insert statement: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
2025-12-29 20:01:55 +03:00
return
}
defer stmt.Close()
var addedCount int
for i, wordReq := range req.Words {
2025-12-29 20:01:55 +03:00
var id int
dictionaryID := defaultDictID
2025-12-29 20:01:55 +03:00
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
}
2025-12-29 20:01:55 +03:00
}
err := stmt.QueryRow(wordReq.Name, wordReq.Translation, wordReq.Description, dictionaryID, userID).Scan(&id)
2025-12-29 20:01:55 +03:00
if err != nil {
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)
2025-12-29 20:01:55 +03:00
return
}
addedCount++
log.Printf("Successfully added word id=%d: name='%s', dict_id=%d", id, wordReq.Name, dictionaryID)
2025-12-29 20:01:55 +03:00
}
if err := tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
2025-12-29 20:01:55 +03:00
return
}
log.Printf("Successfully added %d words for user_id=%d", addedCount, userID)
setCORSHeaders(w)
2025-12-29 20:01:55 +03:00
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": fmt.Sprintf("Added %d words", addedCount),
"added": addedCount,
})
}
func (a *App) getTestWordsHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("getTestWordsHandler called: %s %s", r.Method, r.URL.Path)
setCORSHeaders(w)
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
// Get config_id from query parameter (required)
configIDStr := r.URL.Query().Get("config_id")
if configIDStr == "" {
sendErrorWithCORS(w, "config_id parameter is required", http.StatusBadRequest)
return
}
configID, err := strconv.Atoi(configIDStr)
if err != nil {
sendErrorWithCORS(w, "invalid config_id parameter", http.StatusBadRequest)
return
}
// Get words_count from config (verify ownership)
2025-12-29 20:01:55 +03:00
var wordsCount int
err = a.DB.QueryRow("SELECT words_count FROM configs WHERE id = $1 AND user_id = $2", configID, userID).Scan(&wordsCount)
2025-12-29 20:01:55 +03:00
if err != nil {
if err == sql.ErrNoRows {
sendErrorWithCORS(w, "config not found", http.StatusNotFound)
return
}
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
// Get dictionary IDs for this config
var dictionaryIDs []int
dictQuery := `
SELECT dictionary_id
FROM config_dictionaries
WHERE config_id = $1
`
dictRows, err := a.DB.Query(dictQuery, configID)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
defer dictRows.Close()
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(`
2025-12-29 20:01:55 +03:00
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
2025-12-29 20:01:55 +03:00
// Group 1: success <= 3, sorted by success ASC, then last_success_at ASC (NULL first)
group1Query := `
SELECT ` + baseSelect + `
` + baseFrom + `
AND COALESCE(p.success, 0) <= 3
ORDER BY
COALESCE(p.success, 0) ASC,
CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END,
p.last_success_at ASC
LIMIT $` + fmt.Sprintf("%d", len(dictArgs)+1)
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%
2025-12-29 20:01:55 +03:00
// Exclude words already in group1
group2Exclude := ""
group2Args := make([]interface{}, 0)
group2Args = append(group2Args, dictArgs...)
if len(group1WordIDs) > 0 {
excludePlaceholders := make([]string, 0, len(group1WordIDs))
idx := len(dictArgs) + 1
for wordID := range group1WordIDs {
excludePlaceholders = append(excludePlaceholders, fmt.Sprintf("$%d", idx))
group2Args = append(group2Args, wordID)
idx++
}
group2Exclude = " AND w.id NOT IN (" + strings.Join(excludePlaceholders, ",") + ")"
}
group2Query := `
SELECT ` + baseSelect + `
` + baseFrom + `
` + group2Exclude + `
ORDER BY
(COALESCE(p.failure, 0) + 1.0) / (COALESCE(p.success, 0) + 1.0) DESC,
2025-12-29 20:01:55 +03:00
CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END,
p.last_success_at ASC
LIMIT $` + fmt.Sprintf("%d", len(group2Args)+1)
group2Args = append(group2Args, group2Count)
2025-12-29 20:01:55 +03:00
group2Rows, err := a.DB.Query(group2Query, group2Args...)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
defer group2Rows.Close()
group2Words := make([]Word, 0)
group2WordIDs := make(map[int]bool)
for group2Rows.Next() {
2025-12-29 20:01:55 +03:00
var word Word
var lastSuccess, lastFailure sql.NullString
err := group2Rows.Scan(
&word.ID,
&word.Name,
&word.Translation,
&word.Description,
&word.Success,
&word.Failure,
&lastSuccess,
&lastFailure,
)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
if lastSuccess.Valid {
word.LastSuccess = &lastSuccess.String
}
if lastFailure.Valid {
word.LastFailure = &lastFailure.String
}
group2Words = append(group2Words, word)
group2WordIDs[word.ID] = true
}
// Group 3: All remaining words, sorted by last_success_at ASC (NULL first)
// Exclude words already in group1 and group2
allExcludedIDs := make(map[int]bool)
for id := range group1WordIDs {
allExcludedIDs[id] = true
}
for id := range group2WordIDs {
allExcludedIDs[id] = true
}
group3Exclude := ""
group3Args := make([]interface{}, 0)
group3Args = append(group3Args, dictArgs...)
if len(allExcludedIDs) > 0 {
excludePlaceholders := make([]string, 0, len(allExcludedIDs))
idx := len(dictArgs) + 1
for wordID := range allExcludedIDs {
excludePlaceholders = append(excludePlaceholders, fmt.Sprintf("$%d", idx))
group3Args = append(group3Args, wordID)
idx++
}
group3Exclude = " AND w.id NOT IN (" + strings.Join(excludePlaceholders, ",") + ")"
}
// Calculate how many words we still need from group 3
wordsCollected := len(group1Words) + len(group2Words)
group3Needed := wordsCount - wordsCollected
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
}
2025-12-29 20:01:55 +03:00
var req TestProgressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("Error decoding request: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
return
}
log.Printf("Received %d word updates, config_id: %v, user_id: %d", len(req.Words), req.ConfigID, userID)
2025-12-29 20:01:55 +03:00
tx, err := a.DB.Begin()
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Create unique constraint for (word_id, user_id) if not exists
tx.Exec("CREATE UNIQUE INDEX IF NOT EXISTS progress_word_user_unique ON progress(word_id, user_id)")
2025-12-29 20:01:55 +03:00
stmt, err := tx.Prepare(`
INSERT INTO progress (word_id, user_id, success, failure, last_success_at, last_failure_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (word_id, user_id)
2025-12-29 20:01:55 +03:00
DO UPDATE SET
success = EXCLUDED.success,
failure = EXCLUDED.failure,
last_success_at = COALESCE(EXCLUDED.last_success_at, progress.last_success_at),
last_failure_at = COALESCE(EXCLUDED.last_failure_at, progress.last_failure_at)
`)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
defer stmt.Close()
for _, wordUpdate := range req.Words {
// Convert pointers to values for logging
lastSuccessStr := "nil"
if wordUpdate.LastSuccessAt != nil {
lastSuccessStr = *wordUpdate.LastSuccessAt
}
lastFailureStr := "nil"
if wordUpdate.LastFailureAt != nil {
lastFailureStr = *wordUpdate.LastFailureAt
}
log.Printf("Updating word %d: success=%d, failure=%d, last_success_at=%s, last_failure_at=%s",
wordUpdate.ID, wordUpdate.Success, wordUpdate.Failure, lastSuccessStr, lastFailureStr)
// 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,
2025-12-29 20:01:55 +03:00
wordUpdate.Success,
wordUpdate.Failure,
lastSuccess,
lastFailure,
)
if err != nil {
log.Printf("Error executing update for word %d: %v", wordUpdate.ID, err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
}
if err := tx.Commit(); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
// 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)
2025-12-29 20:01:55 +03:00
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)
2025-12-29 20:01:55 +03:00
w.WriteHeader(http.StatusOK)
return
}
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
query := `
SELECT id, name, words_count, max_cards, try_message
FROM configs
WHERE user_id = $1
2025-12-29 20:01:55 +03:00
ORDER BY id
`
rows, err := a.DB.Query(query, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
2025-12-29 20:01:55 +03:00
return
}
defer rows.Close()
configs := make([]Config, 0)
for rows.Next() {
var config Config
var maxCards sql.NullInt64
err := rows.Scan(
&config.ID,
&config.Name,
&config.WordsCount,
&maxCards,
&config.TryMessage,
)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
2025-12-29 20:01:55 +03:00
return
}
if maxCards.Valid {
maxCardsVal := int(maxCards.Int64)
config.MaxCards = &maxCardsVal
}
configs = append(configs, config)
}
setCORSHeaders(w)
2025-12-29 20:01:55 +03:00
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(configs)
}
func (a *App) getDictionariesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
2025-12-29 20:01:55 +03:00
w.WriteHeader(http.StatusOK)
return
}
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
query := `
SELECT
d.id,
d.name,
COALESCE(COUNT(w.id), 0) as words_count
FROM dictionaries d
LEFT JOIN words w ON d.id = w.dictionary_id
WHERE d.user_id = $1
2025-12-29 20:01:55 +03:00
GROUP BY d.id, d.name
ORDER BY d.id
`
rows, err := a.DB.Query(query, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
2025-12-29 20:01:55 +03:00
return
}
defer rows.Close()
dictionaries := make([]Dictionary, 0)
for rows.Next() {
var dict Dictionary
err := rows.Scan(
&dict.ID,
&dict.Name,
&dict.WordsCount,
)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
2025-12-29 20:01:55 +03:00
return
}
dictionaries = append(dictionaries, dict)
}
setCORSHeaders(w)
2025-12-29 20:01:55 +03:00
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(dictionaries)
}
func (a *App) addDictionaryHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
2025-12-29 20:01:55 +03:00
w.WriteHeader(http.StatusOK)
return
}
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
var req DictionaryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
if req.Name == "" {
sendErrorWithCORS(w, "Имя словаря обязательно", http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
var id int
err := a.DB.QueryRow(`
INSERT INTO dictionaries (name, user_id)
VALUES ($1, $2)
2025-12-29 20:01:55 +03:00
RETURNING id
`, req.Name, userID).Scan(&id)
2025-12-29 20:01:55 +03:00
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(map[string]interface{}{
"id": id,
"name": req.Name,
})
}
func (a *App) updateDictionaryHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
vars := mux.Vars(r)
dictionaryID := vars["id"]
var req DictionaryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
if req.Name == "" {
sendErrorWithCORS(w, "Имя словаря обязательно", http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
result, err := a.DB.Exec(`
UPDATE dictionaries
SET name = $1
WHERE id = $2 AND user_id = $3
`, req.Name, dictionaryID, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if rowsAffected == 0 {
http.Error(w, "Dictionary not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Dictionary updated successfully",
})
}
func (a *App) deleteDictionaryHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
vars := mux.Vars(r)
dictionaryID := vars["id"]
// Prevent deletion of default dictionary (id = 0)
if dictionaryID == "0" {
sendErrorWithCORS(w, "Cannot delete default dictionary", http.StatusBadRequest)
return
}
// Verify ownership
var ownerID int
err := a.DB.QueryRow("SELECT user_id FROM dictionaries WHERE id = $1", dictionaryID).Scan(&ownerID)
if err != nil || ownerID != userID {
sendErrorWithCORS(w, "Dictionary not found", http.StatusNotFound)
2025-12-29 20:01:55 +03:00
return
}
tx, err := a.DB.Begin()
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
defer tx.Rollback()
// Delete all words from this dictionary (progress will be deleted automatically due to CASCADE)
_, err = tx.Exec(`
DELETE FROM words
WHERE dictionary_id = $1
`, dictionaryID)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
// Delete all config-dictionary associations (will be deleted automatically due to CASCADE, but doing explicitly for clarity)
_, err = tx.Exec(`
DELETE FROM config_dictionaries
WHERE dictionary_id = $1
`, dictionaryID)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
// Delete the dictionary
result, err := tx.Exec("DELETE FROM dictionaries WHERE id = $1", dictionaryID)
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
if rowsAffected == 0 {
sendErrorWithCORS(w, "Dictionary not found", http.StatusNotFound)
return
}
if err := tx.Commit(); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Dictionary deleted successfully. All words and configuration associations have been deleted.",
})
}
func (a *App) getConfigDictionariesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusOK)
return
}
vars := mux.Vars(r)
configID := vars["id"]
query := `
SELECT dictionary_id
FROM config_dictionaries
WHERE config_id = $1
ORDER BY dictionary_id
`
rows, err := a.DB.Query(query, configID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
dictionaryIDs := make([]int, 0)
for rows.Next() {
var dictID int
err := rows.Scan(&dictID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dictionaryIDs = append(dictionaryIDs, dictID)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(map[string]interface{}{
"dictionary_ids": dictionaryIDs,
})
}
func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
2025-12-29 20:01:55 +03:00
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)
2025-12-29 20:01:55 +03:00
// Get configs
configsQuery := `
SELECT id, name, words_count, max_cards, try_message
FROM configs
WHERE user_id = $1
2025-12-29 20:01:55 +03:00
ORDER BY id
`
configsRows, err := a.DB.Query(configsQuery, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer configsRows.Close()
configs := make([]Config, 0)
for configsRows.Next() {
var config Config
var maxCards sql.NullInt64
err := configsRows.Scan(
&config.ID,
&config.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
2025-12-29 20:01:55 +03:00
GROUP BY d.id, d.name
ORDER BY d.id
`
dictsRows, err := a.DB.Query(dictsQuery, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer dictsRows.Close()
dictionaries := make([]Dictionary, 0)
for dictsRows.Next() {
var dict Dictionary
err := dictsRows.Scan(
&dict.ID,
&dict.Name,
&dict.WordsCount,
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dictionaries = append(dictionaries, dict)
}
response := TestConfigsAndDictionariesResponse{
Configs: configs,
Dictionaries: dictionaries,
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(response)
}
func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
2025-12-29 20:01:55 +03:00
w.WriteHeader(http.StatusOK)
return
}
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
var req ConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
if req.Name == "" {
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
if req.WordsCount <= 0 {
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
tx, err := a.DB.Begin()
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
2025-12-29 20:01:55 +03:00
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)
2025-12-29 20:01:55 +03:00
RETURNING id
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, userID).Scan(&id)
2025-12-29 20:01:55 +03:00
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Insert dictionary associations if provided
if len(req.DictionaryIDs) > 0 {
stmt, err := tx.Prepare(`
INSERT INTO config_dictionaries (config_id, dictionary_id)
VALUES ($1, $2)
`)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer stmt.Close()
for _, dictID := range req.DictionaryIDs {
_, err := stmt.Exec(id, dictID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
if err := tx.Commit(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Config created successfully",
"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
}
2025-12-29 20:01:55 +03:00
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
}
2025-12-29 20:01:55 +03:00
var req ConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
if req.Name == "" {
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
if req.WordsCount <= 0 {
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
2025-12-29 20:01:55 +03:00
return
}
tx, err := a.DB.Begin()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer tx.Rollback()
result, err := tx.Exec(`
UPDATE configs
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
}
2025-12-29 20:01:55 +03:00
vars := mux.Vars(r)
configID := vars["id"]
result, err := a.DB.Exec("DELETE FROM configs WHERE id = $1 AND user_id = $2", configID, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
if rowsAffected == 0 {
sendErrorWithCORS(w, "Config not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Config deleted successfully",
})
}
func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
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)
2025-12-29 20:01:55 +03:00
// Опционально обновляем 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
2025-12-29 20:01:55 +03:00
FROM
2025-12-30 18:27:12 +03:00
projects p
LEFT JOIN
weekly_goals wg ON wg.project_id = p.id
AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
2025-12-29 20:01:55 +03:00
LEFT JOIN
weekly_report_mv wr
2025-12-30 18:27:12 +03:00
ON p.id = wr.project_id
AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year
AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week
2025-12-29 20:01:55 +03:00
WHERE
p.deleted = FALSE AND p.user_id = $1
2025-12-29 20:01:55 +03:00
ORDER BY
total_score DESC
`
rows, err := a.DB.Query(query, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
log.Printf("Error querying weekly stats: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
projects := make([]WeeklyProjectStats, 0)
// Группы для расчета среднего по priority
groups := make(map[int][]float64)
for rows.Next() {
var project WeeklyProjectStats
2025-12-30 18:27:12 +03:00
var minGoalScore sql.NullFloat64
2025-12-29 20:01:55 +03:00
var maxGoalScore sql.NullFloat64
var priority sql.NullInt64
err := rows.Scan(
&project.ProjectName,
&project.TotalScore,
2025-12-30 18:27:12 +03:00
&minGoalScore,
2025-12-29 20:01:55 +03:00
&maxGoalScore,
&priority,
)
if err != nil {
log.Printf("Error scanning weekly stats row: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
2025-12-30 18:27:12 +03:00
if minGoalScore.Valid {
project.MinGoalScore = minGoalScore.Float64
} else {
project.MinGoalScore = 0
}
2025-12-29 20:01:55 +03:00
if maxGoalScore.Valid {
maxGoalVal := maxGoalScore.Float64
project.MaxGoalScore = &maxGoalVal
}
var priorityVal int
if priority.Valid {
priorityVal = int(priority.Int64)
project.Priority = &priorityVal
}
// Расчет calculated_score по формуле из n8n
totalScore := project.TotalScore
2025-12-30 18:27:12 +03:00
minGoalScoreVal := project.MinGoalScore
2025-12-29 20:01:55 +03:00
var maxGoalScoreVal float64
if project.MaxGoalScore != nil {
maxGoalScoreVal = *project.MaxGoalScore
}
// Параметры бонуса в зависимости от priority
var extraBonusLimit float64 = 20
if priorityVal == 1 {
extraBonusLimit = 50
} else if priorityVal == 2 {
extraBonusLimit = 35
}
// Расчет базового прогресса
var baseProgress float64
2025-12-30 18:27:12 +03:00
if minGoalScoreVal > 0 {
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
2025-12-29 20:01:55 +03:00
}
// Расчет экстра прогресса
var extraProgress float64
2025-12-30 18:27:12 +03:00
denominator := maxGoalScoreVal - minGoalScoreVal
if denominator > 0 && totalScore > minGoalScoreVal {
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
2025-12-29 20:01:55 +03:00
extraProgress = (excess / denominator) * extraBonusLimit
}
resultScore := baseProgress + extraProgress
project.CalculatedScore = roundToTwoDecimals(resultScore)
// Группировка для итогового расчета
2025-12-30 18:27:12 +03:00
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
if minGoalScoreVal > 0 {
if _, exists := groups[priorityVal]; !exists {
groups[priorityVal] = make([]float64, 0)
}
groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore)
2025-12-29 20:01:55 +03:00
}
projects = append(projects, project)
}
// Вычисляем общий процент выполнения
total := calculateOverallProgress(groups)
2025-12-29 20:01:55 +03:00
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)
// Не возвращаем ошибку, чтобы приложение могло запуститься
}
// 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
}
2025-12-29 20:01:55 +03:00
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)
}
2025-12-29 20:01:55 +03:00
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,
2025-12-29 20:01:55 +03:00
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
2025-12-29 20:01:55 +03:00
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)
}
}
2025-12-29 20:01:55 +03:00
return nil
}
// startWeeklyGoalsScheduler запускает планировщик для автоматической фиксации целей на неделю
// каждый понедельник в 6:00 утра в указанном часовом поясе
func (a *App) startWeeklyGoalsScheduler() {
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
timezoneStr := getEnv("TIMEZONE", "UTC")
2025-12-30 18:27:12 +03:00
log.Printf("Loading timezone for weekly goals scheduler: '%s'", timezoneStr)
2025-12-29 20:01:55 +03:00
// Загружаем часовой пояс
loc, err := time.LoadLocation(timezoneStr)
if err != nil {
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
2025-12-30 18:27:12 +03:00
log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'")
2025-12-29 20:01:55 +03:00
loc = time.UTC
2025-12-30 18:27:12 +03:00
timezoneStr = "UTC"
2025-12-29 20:01:55 +03:00
} else {
2025-12-30 18:27:12 +03:00
log.Printf("Weekly goals scheduler timezone set to: %s", timezoneStr)
2025-12-29 20:01:55 +03:00
}
2025-12-30 18:27:12 +03:00
// Логируем текущее время в указанном часовом поясе для проверки
now := time.Now().In(loc)
log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
log.Printf("Next weekly goals setup will be on Monday at: 06:00 %s (cron: '0 6 * * 1')", timezoneStr)
2025-12-29 20:01:55 +03:00
// Создаем планировщик с указанным часовым поясом
c := cron.New(cron.WithLocation(loc))
// Добавляем задачу: каждый понедельник в 6:00 утра
// Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1)
_, err = c.AddFunc("0 6 * * 1", func() {
2025-12-30 18:27:12 +03:00
now := time.Now().In(loc)
log.Printf("Scheduled task: Setting up weekly goals (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
2025-12-29 20:01:55 +03:00
if err := a.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()
2025-12-30 18:27:12 +03:00
log.Printf("Weekly goals scheduler started: every Monday at 6:00 AM %s", timezoneStr)
2025-12-29 20:01:55 +03:00
// Планировщик будет работать в фоновом режиме
}
// 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
2025-12-29 20:01:55 +03:00
FROM
2025-12-30 18:27:12 +03:00
projects p
LEFT JOIN
weekly_goals wg ON wg.project_id = p.id
AND wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
2025-12-29 20:01:55 +03:00
LEFT JOIN
weekly_report_mv wr
2025-12-30 18:27:12 +03:00
ON p.id = wr.project_id
AND EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER = wr.report_year
AND EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER = wr.report_week
2025-12-29 20:01:55 +03:00
WHERE
2025-12-30 18:27:12 +03:00
p.deleted = FALSE
2025-12-29 20:01:55 +03:00
ORDER BY
total_score DESC
`
rows, err := a.DB.Query(query)
if err != nil {
log.Printf("Error querying weekly stats: %v", err)
return nil, fmt.Errorf("error querying weekly stats: %w", err)
}
defer rows.Close()
projects := make([]WeeklyProjectStats, 0)
// Группы для расчета среднего по priority
groups := make(map[int][]float64)
for rows.Next() {
var project WeeklyProjectStats
2025-12-30 18:27:12 +03:00
var minGoalScore sql.NullFloat64
2025-12-29 20:01:55 +03:00
var maxGoalScore sql.NullFloat64
var priority sql.NullInt64
err := rows.Scan(
&project.ProjectName,
&project.TotalScore,
2025-12-30 18:27:12 +03:00
&minGoalScore,
2025-12-29 20:01:55 +03:00
&maxGoalScore,
&priority,
)
if err != nil {
log.Printf("Error scanning weekly stats row: %v", err)
return nil, fmt.Errorf("error scanning weekly stats row: %w", err)
}
2025-12-30 18:27:12 +03:00
if minGoalScore.Valid {
project.MinGoalScore = minGoalScore.Float64
} else {
project.MinGoalScore = 0
}
2025-12-29 20:01:55 +03:00
if maxGoalScore.Valid {
maxGoalVal := maxGoalScore.Float64
project.MaxGoalScore = &maxGoalVal
}
var priorityVal int
if priority.Valid {
priorityVal = int(priority.Int64)
project.Priority = &priorityVal
}
// Расчет calculated_score по формуле из n8n
totalScore := project.TotalScore
2025-12-30 18:27:12 +03:00
minGoalScoreVal := project.MinGoalScore
2025-12-29 20:01:55 +03:00
var maxGoalScoreVal float64
if project.MaxGoalScore != nil {
maxGoalScoreVal = *project.MaxGoalScore
}
// Параметры бонуса в зависимости от priority
var extraBonusLimit float64 = 20
if priorityVal == 1 {
extraBonusLimit = 50
} else if priorityVal == 2 {
extraBonusLimit = 35
}
// Расчет базового прогресса
var baseProgress float64
2025-12-30 18:27:12 +03:00
if minGoalScoreVal > 0 {
baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0
2025-12-29 20:01:55 +03:00
}
// Расчет экстра прогресса
var extraProgress float64
2025-12-30 18:27:12 +03:00
denominator := maxGoalScoreVal - minGoalScoreVal
if denominator > 0 && totalScore > minGoalScoreVal {
excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal
2025-12-29 20:01:55 +03:00
extraProgress = (excess / denominator) * extraBonusLimit
}
resultScore := baseProgress + extraProgress
project.CalculatedScore = roundToTwoDecimals(resultScore)
// Группировка для итогового расчета
2025-12-30 18:27:12 +03:00
// Проекты с minGoal = 0 или null не учитываются в общем проценте выполнения
if minGoalScoreVal > 0 {
if _, exists := groups[priorityVal]; !exists {
groups[priorityVal] = make([]float64, 0)
}
groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore)
2025-12-29 20:01:55 +03:00
}
projects = append(projects, project)
}
// Вычисляем общий процент выполнения
total := calculateOverallProgress(groups)
2025-12-29 20:01:55 +03:00
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
}
2025-12-29 20:01:55 +03:00
// formatDailyReport форматирует данные проектов в сообщение для Telegram
// Формат аналогичен JS коду из n8n
func (a *App) formatDailyReport(data *WeeklyStatsResponse) string {
if data == nil || len(data.Projects) == 0 {
return ""
}
// Заголовок сообщения
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 отправляет персональные ежедневные отчеты всем пользователям
2025-12-29 20:01:55 +03:00
func (a *App) sendDailyReport() error {
log.Printf("Scheduled task: Sending daily reports")
2025-12-29 20:01:55 +03:00
userIDs, err := a.getAllUsersWithTelegram()
2025-12-29 20:01:55 +03:00
if err != nil {
return fmt.Errorf("error getting users: %w", err)
2025-12-29 20:01:55 +03:00
}
if len(userIDs) == 0 {
log.Printf("No users with Telegram connected, skipping daily report")
2025-12-29 20:01:55 +03:00
return nil
}
for _, userID := range userIDs {
data, err := a.getWeeklyStatsDataForUser(userID)
if err != nil {
log.Printf("Error getting data for user %d: %v", userID, err)
continue
}
message := a.formatDailyReport(data)
if message == "" {
continue
}
if err := a.sendTelegramMessageToUser(userID, message); err != nil {
log.Printf("Error sending daily report to user %d: %v", userID, err)
} else {
log.Printf("Daily report sent to user %d", userID)
}
}
2025-12-29 20:01:55 +03:00
return nil
}
// startDailyReportScheduler запускает планировщик для ежедневного отчета
2025-12-30 18:27:12 +03:00
// каждый день в 23:59 в указанном часовом поясе
2025-12-29 20:01:55 +03:00
func (a *App) startDailyReportScheduler() {
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
timezoneStr := getEnv("TIMEZONE", "UTC")
2025-12-30 18:27:12 +03:00
log.Printf("Loading timezone for daily report scheduler: '%s'", timezoneStr)
2025-12-29 20:01:55 +03:00
// Загружаем часовой пояс
loc, err := time.LoadLocation(timezoneStr)
if err != nil {
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
2025-12-30 18:27:12 +03:00
log.Printf("Note: Timezone must be in IANA format (e.g., 'Europe/Moscow', 'America/New_York'), not 'UTC+3'")
2025-12-29 20:01:55 +03:00
loc = time.UTC
2025-12-30 18:27:12 +03:00
timezoneStr = "UTC"
2025-12-29 20:01:55 +03:00
} else {
log.Printf("Daily report scheduler timezone set to: %s", timezoneStr)
}
2025-12-30 18:27:12 +03:00
// Логируем текущее время в указанном часовом поясе для проверки
now := time.Now().In(loc)
log.Printf("Current time in scheduler timezone (%s): %s", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
log.Printf("Next daily report will be sent at: 23:59 %s (cron: '59 23 * * *')", timezoneStr)
2025-12-29 20:01:55 +03:00
// Создаем планировщик с указанным часовым поясом
c := cron.New(cron.WithLocation(loc))
2025-12-30 18:27:12 +03:00
// Добавляем задачу: каждый день в 23:59
// Cron выражение: "59 23 * * *" означает: минута=59, час=23, любой день месяца, любой месяц, любой день недели
_, err = c.AddFunc("59 23 * * *", func() {
now := time.Now().In(loc)
log.Printf("Scheduled task: Sending daily report (timezone: %s, local time: %s)", timezoneStr, now.Format("2006-01-02 15:04:05 MST"))
2025-12-29 20:01:55 +03:00
if err := a.sendDailyReport(); err != nil {
log.Printf("Error in scheduled daily report: %v", err)
}
})
if err != nil {
log.Printf("Error adding cron job for daily report: %v", err)
return
}
// Запускаем планировщик
c.Start()
2025-12-30 18:27:12 +03:00
log.Printf("Daily report scheduler started: every day at 23:59 %s", timezoneStr)
2025-12-29 20:01:55 +03:00
// Планировщик будет работать в фоновом режиме
}
// 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"
}
2025-12-29 20:01:55 +03:00
func main() {
// Читаем версию приложения
version := readVersion()
log.Printf("========================================")
log.Printf("Play Life Backend v%s", version)
log.Printf("========================================")
2025-12-29 20:01:55 +03:00
// Загружаем переменные окружения из .env файла (если существует)
// Сначала пробуем загрузить из корня проекта, затем из текущей директории
// Игнорируем ошибку, если файл не найден
godotenv.Load("../.env") // Пробуем корневой .env
godotenv.Load(".env") // Пробуем локальный .env
dbHost := getEnv("DB_HOST", "localhost")
dbPort := getEnv("DB_PORT", "5432")
dbUser := getEnv("DB_USER", "playeng")
dbPassword := getEnv("DB_PASSWORD", "playeng")
dbName := getEnv("DB_NAME", "playeng")
// Логируем параметры подключения к БД (без пароля)
log.Printf("Database connection parameters: host=%s port=%s user=%s dbname=%s", dbHost, dbPort, dbUser, dbName)
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
dbHost, dbPort, dbUser, dbPassword, dbName)
var db *sql.DB
var err error
// Retry connection
for i := 0; i < 10; i++ {
db, err = sql.Open("postgres", dsn)
if err == nil {
err = db.Ping()
if err == nil {
break
}
}
if i < 9 {
time.Sleep(2 * time.Second)
}
}
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
log.Printf("Successfully connected to database: %s@%s:%s/%s", dbUser, dbHost, dbPort, dbName)
defer db.Close()
// Telegram бот теперь загружается из БД при необходимости
// Webhook будет настроен автоматически при сохранении bot token через UI
2025-12-29 20:01:55 +03:00
// JWT secret from env or generate random
jwtSecret := getEnv("JWT_SECRET", "")
if jwtSecret == "" {
// Generate random secret if not provided (not recommended for production)
b := make([]byte, 32)
rand.Read(b)
jwtSecret = base64.StdEncoding.EncodeToString(b)
log.Printf("WARNING: JWT_SECRET not set, using randomly generated secret. Set JWT_SECRET env var for production.")
}
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)
}
2025-12-29 20:01:55 +03:00
} else {
log.Printf("WEBHOOK_BASE_URL not set. Webhook will not be configured.")
2025-12-29 20:01:55 +03:00
}
}
} else {
log.Printf("WARNING: TELEGRAM_BOT_TOKEN not set in environment")
2025-12-29 20:01:55 +03:00
}
// Инициализируем БД для 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")
2025-12-29 20:01:55 +03:00
// Запускаем планировщик для автоматической фиксации целей на неделю
app.startWeeklyGoalsScheduler()
2025-12-30 18:27:12 +03:00
// Запускаем планировщик для ежедневного отчета в 23:59
2025-12-29 20:01:55 +03:00
app.startDailyReportScheduler()
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)
2025-12-29 20:01:55 +03:00
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)
2025-12-29 20:01:55 +03:00
r.HandleFunc("/admin", app.adminHandler).Methods("GET")
r.HandleFunc("/admin.html", app.adminHandler).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}/postpone", app.postponeTaskHandler).Methods("POST", "OPTIONS")
// Admin operations
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
2025-12-29 20:01:55 +03:00
port := getEnv("PORT", "8080")
log.Printf("Server starting on port %s", port)
log.Printf("Registered public routes: /api/auth/register, /api/auth/login, /api/auth/refresh, webhooks")
log.Printf("All other routes require authentication via Bearer token")
2025-12-29 20:01:55 +03:00
log.Printf("Admin panel available at: http://localhost:%s/admin.html", port)
log.Fatal(http.ListenAndServe(":"+port, r))
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getMapKeys возвращает список ключей из map
func getMapKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// setupTelegramWebhook настраивает webhook для Telegram бота
func setupTelegramWebhook(botToken, webhookURL string) error {
apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook", botToken)
log.Printf("Setting up Telegram webhook: apiURL=%s, webhookURL=%s", apiURL, webhookURL)
2025-12-29 20:01:55 +03:00
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)
2025-12-29 20:01:55 +03:00
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))
2025-12-29 20:01:55 +03:00
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("telegram API returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
// Декодируем из уже прочитанных байтов
2025-12-29 20:01:55 +03:00
var result map[string]interface{}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
2025-12-29 20:01:55 +03:00
return fmt.Errorf("failed to decode response: %w", err)
}
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, если нет данных
func calculateOverallProgress(groups map[int][]float64) *float64 {
// Находим среднее внутри каждой группы
groupAverages := make([]float64, 0)
for priorityVal, scores := range groups {
if len(scores) > 0 {
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 = append(groupAverages, avg)
}
}
// Находим среднее между всеми группами
var total *float64
if len(groupAverages) > 0 {
sum := 0.0
for _, avg := range groupAverages {
sum += avg
}
overallProgress := sum / float64(len(groupAverages))
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
2025-12-29 20:01:55 +03:00
}
defer rows.Close()
2025-12-29 20:01:55 +03:00
var userIDs []int
for rows.Next() {
var userID int
if err := rows.Scan(&userID); err == nil {
userIDs = append(userIDs, userID)
}
2025-12-29 20:01:55 +03:00
}
return userIDs, nil
2025-12-29 20:01:55 +03:00
}
// utf16OffsetToUTF8 конвертирует UTF-16 offset в UTF-8 byte offset
func utf16OffsetToUTF8(text string, utf16Offset int) int {
utf16Runes := utf16.Encode([]rune(text))
if utf16Offset >= len(utf16Runes) {
return len(text)
}
// Конвертируем 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) {
2025-12-29 20:01:55 +03:00
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)
2025-12-29 20:01:55 +03:00
if err != nil {
log.Printf("Error inserting message data: %v", err)
return nil, fmt.Errorf("error inserting data: %w", err)
}
} else {
// Если nodes нет, используем исходный текст для processedText
processedText = fullText
log.Printf("No nodes found in Telegram message, message will not be saved to database")
}
// Формируем ответ
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)
2025-12-29 20:01:55 +03:00
}
// processMessageWithoutTelegram обрабатывает текст сообщения: парсит ноды, сохраняет в БД, но НЕ отправляет в Telegram
func (a *App) processMessageWithoutTelegram(rawText string, userID *int) (*ProcessedEntry, error) {
return a.processMessageInternal(rawText, false, userID)
2025-12-29 20:01:55 +03:00
}
// processMessageInternal - внутренняя функция обработки сообщения
// sendToTelegram определяет, нужно ли отправлять сообщение в Telegram
func (a *App) processMessageInternal(rawText string, sendToTelegram bool, userID *int) (*ProcessedEntry, error) {
2025-12-29 20:01:55 +03:00
rawText = strings.TrimSpace(rawText)
// Регулярное выражение для поиска **[Project][+| -][Score]**
regex := regexp.MustCompile(`\*\*(.+?)([+-])([\d.]+)\*\*`)
nodes := make([]ProcessedNode, 0)
nodeCounter := 0
// Ищем все node и заменяем их в тексте на плейсхолдеры ${0}, ${1} и т.д.
processedText := regex.ReplaceAllStringFunc(rawText, func(fullMatch string) string {
matches := regex.FindStringSubmatch(fullMatch)
if len(matches) != 4 {
return fullMatch
}
projectName := strings.TrimSpace(matches[1])
sign := matches[2]
scoreString := matches[3]
score, err := strconv.ParseFloat(scoreString, 64)
if err != nil {
log.Printf("Error parsing score: %v", err)
return fullMatch
}
if sign == "-" {
score = -score
}
// Добавляем данные в массив nodes
nodes = append(nodes, ProcessedNode{
Project: projectName,
Score: score,
})
placeholder := fmt.Sprintf("${%d}", nodeCounter)
nodeCounter++
return placeholder
})
// Удаляем пустые строки и лишние пробелы
lines := strings.Split(processedText, "\n")
cleanedLines := make([]string, 0)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
cleanedLines = append(cleanedLines, trimmed)
}
}
processedText = strings.Join(cleanedLines, "\n")
// Формируем Markdown (Legacy) контент: заменяем ** на *
markdownText := strings.ReplaceAll(rawText, "**", "*")
// Используем текущее время
createdDate := time.Now().UTC().Format(time.RFC3339)
// Вставляем данные в БД только если есть nodes
if len(nodes) > 0 {
err := a.insertMessageData(processedText, createdDate, nodes, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
log.Printf("Error inserting message data: %v", err)
return nil, fmt.Errorf("error inserting data: %w", err)
}
} else {
// Если nodes нет, используем исходный текст для processedText
processedText = rawText
if sendToTelegram {
log.Printf("No nodes found in text, message will be sent to Telegram but not saved to database")
} else {
log.Printf("No nodes found in text, message will be ignored (not saved to database and not sent to Telegram)")
}
}
// Формируем ответ
response := &ProcessedEntry{
Text: processedText,
CreatedDate: createdDate,
Nodes: nodes,
Raw: rawText,
Markdown: markdownText,
}
// Отправляем дублирующее сообщение в Telegram только если указано
if sendToTelegram && userID != nil {
if err := a.sendTelegramMessageToUser(*userID, rawText); err != nil {
log.Printf("Error sending Telegram message: %v", err)
}
2025-12-29 20:01:55 +03:00
}
return response, nil
}
func (a *App) messagePostHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
// Get user ID from context (may be nil for webhook)
var userIDPtr *int
if userID, ok := getUserIDFromContext(r); ok {
userIDPtr = &userID
}
2025-12-29 20:01:55 +03:00
// Парсим входящий запрос - может быть как {body: {text: ...}}, так и {text: ...}
var rawReq map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&rawReq); err != nil {
log.Printf("Error decoding message post request: %v", err)
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
return
}
// Извлекаем text из разных возможных структур
var rawText string
if body, ok := rawReq["body"].(map[string]interface{}); ok {
if text, ok := body["text"].(string); ok {
rawText = text
}
}
// Если не нашли в 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)
2025-12-29 20:01:55 +03:00
if err != nil {
log.Printf("Error processing message: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (a *App) insertMessageData(entryText string, createdDate string, nodes []ProcessedNode, userID *int) error {
2025-12-29 20:01:55 +03:00
// Начинаем транзакцию
tx, err := a.DB.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// 1. UPSERT проектов
projectNames := make(map[string]bool)
for _, node := range nodes {
projectNames[node.Project] = true
}
// Вставляем проекты
for projectName := range projectNames {
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)
}
// Проект уже существует, ничего не делаем
2025-12-29 20:01:55 +03:00
}
}
// 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)
}
2025-12-29 20:01:55 +03:00
if err != nil {
return fmt.Errorf("failed to insert entry: %w", err)
}
// 3. Вставляем nodes
for _, node := range nodes {
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)
}
2025-12-29 20:01:55 +03:00
if err != nil {
return fmt.Errorf("failed to insert node for project %s: %w", node.Project, err)
}
}
// Обновляем 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 (
2025-12-30 18:27:12 +03:00
-- Считаем медиану на основе данных за 3 месяца (12 недель), исключая текущую неделю
2025-12-29 20:01:55 +03:00
SELECT
project_id,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_score) AS median_score
FROM (
SELECT
project_id,
total_score,
2025-12-30 18:27:12 +03:00
report_year,
report_week,
2025-12-29 20:01:55 +03:00
-- Нумеруем недели от новых к старым
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
FROM weekly_report_mv
2025-12-30 18:27:12 +03:00
WHERE
-- Исключаем текущую неделю и все будущие недели
-- Используем сравнение (year, week) < (current_year, current_week) для корректного исключения
(report_year < EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER)
OR (report_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND report_week < EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER)
2025-12-29 20:01:55 +03:00
) sub
2025-12-30 18:27:12 +03:00
WHERE rn <= 12 -- Берем историю за последние 12 недель (3 месяца), исключая текущую неделю
2025-12-29 20:01:55 +03:00
GROUP BY project_id
)
INSERT INTO weekly_goals (
project_id,
goal_year,
goal_week,
min_goal_score,
max_goal_score,
priority
)
SELECT
p.id,
ci.c_year,
ci.c_week,
2025-12-30 18:27:12 +03:00
-- Если нет данных (gm.median_score IS NULL), используем 0 (значение по умолчанию)
2025-12-29 20:01:55 +03:00
COALESCE(gm.median_score, 0) AS min_goal_score,
2025-12-30 18:27:12 +03:00
-- Логика max_score в зависимости от приоритета (только если есть данные)
2025-12-29 20:01:55 +03:00
CASE
2025-12-30 18:27:12 +03:00
WHEN gm.median_score IS NULL THEN NULL
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,
2025-12-29 20:01:55 +03:00
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
2025-12-29 20:01:55 +03:00
ON CONFLICT (project_id, goal_year, goal_week) DO UPDATE
SET
min_goal_score = EXCLUDED.min_goal_score,
max_goal_score = EXCLUDED.max_goal_score,
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) {
2025-12-29 20:01:55 +03:00
selectQuery := `
SELECT
p.name AS project_name,
wg.min_goal_score,
wg.max_goal_score
FROM
weekly_goals wg
JOIN
projects p ON wg.project_id = p.id
WHERE
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
AND p.deleted = FALSE
AND p.user_id = $1
2025-12-29 20:01:55 +03:00
ORDER BY
p.name
`
rows, err := a.DB.Query(selectQuery, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
return nil, fmt.Errorf("error querying weekly goals: %w", err)
2025-12-29 20:01:55 +03:00
}
defer rows.Close()
goals := make([]WeeklyGoalSetup, 0)
for rows.Next() {
var goal WeeklyGoalSetup
var maxGoalScore sql.NullFloat64
err := rows.Scan(
&goal.ProjectName,
&goal.MinGoalScore,
&maxGoalScore,
)
if err != nil {
log.Printf("Error scanning weekly goal row: %v", err)
continue
}
if maxGoalScore.Valid {
goal.MaxGoalScore = maxGoalScore.Float64
} else {
goal.MaxGoalScore = math.NaN()
}
goals = append(goals, goal)
}
return goals, nil
}
// sendWeeklyGoalsTelegramMessage отправляет персональные цели всем пользователям
func (a *App) sendWeeklyGoalsTelegramMessage() error {
userIDs, err := a.getAllUsersWithTelegram()
if err != nil {
return err
}
for _, userID := range userIDs {
goals, err := a.getWeeklyGoalsForUser(userID)
if err != nil {
log.Printf("Error getting goals for user %d: %v", userID, err)
continue
}
message := a.formatWeeklyGoalsMessage(goals)
if message == "" {
continue
}
if err := a.sendTelegramMessageToUser(userID, message); err != nil {
log.Printf("Error sending weekly goals to user %d: %v", userID, err)
}
2025-12-29 20:01:55 +03:00
}
return nil
}
// formatWeeklyGoalsMessage форматирует список целей в сообщение для Telegram
// Формат аналогичен JS коду из n8n
func (a *App) formatWeeklyGoalsMessage(goals []WeeklyGoalSetup) string {
if len(goals) == 0 {
return ""
}
// Заголовок сообщения: "Цели на неделю"
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
2025-12-29 20:01:55 +03:00
ORDER BY
p.name
`
rows, err := a.DB.Query(selectQuery)
if err != nil {
log.Printf("Error querying weekly goals: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error querying weekly goals: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
goals := make([]WeeklyGoalSetup, 0)
for rows.Next() {
var goal WeeklyGoalSetup
var maxGoalScore sql.NullFloat64
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",
})
}
2025-12-29 20:01:55 +03:00
func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
query := `
SELECT
id AS project_id,
name AS project_name,
priority
FROM
projects
WHERE
deleted = FALSE AND user_id = $1
2025-12-29 20:01:55 +03:00
ORDER BY
priority ASC NULLS LAST,
project_name
`
rows, err := a.DB.Query(query, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
log.Printf("Error querying projects: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error querying projects: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
projects := make([]Project, 0)
for rows.Next() {
var project Project
var priority sql.NullInt64
err := rows.Scan(
&project.ProjectID,
&project.ProjectName,
&priority,
)
if err != nil {
log.Printf("Error scanning project row: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError)
return
}
if priority.Valid {
priorityVal := int(priority.Int64)
project.Priority = &priorityVal
}
projects = append(projects, project)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(projects)
}
func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
_ = userID // Will be used in SQL queries
2025-12-29 20:01:55 +03:00
// Читаем тело запроса один раз
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Error reading request body: %v", err)
sendErrorWithCORS(w, "Error reading request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Парсим входящий запрос - может быть как {body: [...]}, так и просто массив
var projectsToUpdate []ProjectPriorityUpdate
// Сначала пробуем декодировать как прямой массив
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)
2025-12-29 20:01:55 +03:00
} else {
_, err = tx.Exec(`
UPDATE projects
SET priority = $1
WHERE id = $2 AND user_id = $3
`, *project.Priority, project.ID, userID)
2025-12-29 20:01:55 +03:00
}
if err != nil {
log.Printf("Error updating project %d priority: %v", project.ID, err)
tx.Rollback()
sendErrorWithCORS(w, fmt.Sprintf("Error updating project %d: %v", project.ID, err), http.StatusInternalServerError)
return
}
}
// Коммитим транзакцию
if err := tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
return
}
// Возвращаем успешный ответ
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": fmt.Sprintf("Updated priorities for %d projects", len(projectsToUpdate)),
"updated": len(projectsToUpdate),
})
}
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,
})
}
2025-12-29 20:01:55 +03:00
func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
// Логирование входящего запроса
log.Printf("=== Todoist Webhook Request ===")
log.Printf("Method: %s", r.Method)
log.Printf("URL: %s", r.URL.String())
log.Printf("Path: %s", r.URL.Path)
2025-12-29 20:01:55 +03:00
log.Printf("RemoteAddr: %s", r.RemoteAddr)
if r.Method == "OPTIONS" {
log.Printf("OPTIONS request, returning OK")
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
// Проверка 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)
2025-12-29 20:01:55 +03:00
// Извлекаем content (title) и description из event_data
log.Printf("Extracting content and description from event_data...")
var title, description string
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",
})
2025-12-29 20:01:55 +03:00
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)
2025-12-29 20:01:55 +03:00
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",
})
2025-12-29 20:01:55 +03:00
return
}
// Проверяем наличие nodes - если их нет, игнорируем сообщение
if len(response.Nodes) == 0 {
log.Printf("Todoist webhook: no nodes found in message, ignoring (not saving to database and not sending to Telegram)")
log.Printf("=== Todoist Webhook Request Ignored (No Nodes) ===")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
2025-12-29 20:01:55 +03:00
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
2025-12-29 20:01:55 +03:00
"message": "Message ignored (no nodes found)",
"ignored": true,
})
return
}
log.Printf("Successfully processed Todoist task, found %d nodes", len(response.Nodes))
if len(response.Nodes) > 0 {
log.Printf("Nodes details:")
for i, node := range response.Nodes {
log.Printf(" Node %d: Project='%s', Score=%f", i+1, node.Project, node.Score)
}
// Отправляем сообщение в 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")
}
2025-12-29 20:01:55 +03:00
} else {
log.Printf("No nodes found, skipping Telegram message")
}
log.Printf("=== Todoist Webhook Request Completed Successfully ===")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
2025-12-29 20:01:55 +03:00
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
2025-12-29 20:01:55 +03:00
"message": "Task processed successfully",
"result": response,
})
}
func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
// Парсим webhook от Telegram
var update TelegramUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
log.Printf("Error decoding Telegram webhook: %v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": false,
"error": "Invalid request body",
})
2025-12-29 20:01:55 +03:00
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 == "" {
2025-12-29 20:01:55 +03:00
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
2025-12-29 20:01:55 +03:00
return
}
entities := message.Entities
2025-12-29 20:01:55 +03:00
if entities == nil {
entities = []TelegramEntity{}
}
userIDPtr := &userID
response, err := a.processTelegramMessage(message.Text, entities, userIDPtr)
2025-12-29 20:01:55 +03:00
if err != nil {
log.Printf("Error processing message: %v", err)
2025-12-29 20:01:55 +03:00
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"result": response,
2025-12-29 20:01:55 +03:00
})
}
func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
userID, ok := getUserIDFromContext(r)
if !ok {
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
return
}
2025-12-29 20:01:55 +03:00
query := `
SELECT
p.name AS project_name,
-- Определяем год и неделю, беря значение из той таблицы, где оно не NULL
COALESCE(wr.report_year, wg.goal_year) AS report_year,
COALESCE(wr.report_week, wg.goal_week) AS report_week,
-- Фактический score: COALESCE(NULL, 0.0000)
COALESCE(wr.total_score, 0.0000) AS total_score,
-- Минимальная цель: COALESCE(NULL, 0.0000)
COALESCE(wg.min_goal_score, 0.0000) AS min_goal_score,
-- Максимальная цель: COALESCE(NULL, 0.0000)
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
2025-12-29 20:01:55 +03:00
ORDER BY
report_year DESC,
report_week DESC,
project_name
`
rows, err := a.DB.Query(query, userID)
2025-12-29 20:01:55 +03:00
if err != nil {
log.Printf("Error querying full statistics: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error querying full statistics: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
statistics := make([]FullStatisticsItem, 0)
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 {
// Вычисляем next_show_at для задачи с repetition_period
periodStr := strings.TrimSpace(repetitionPeriod.String)
isZeroPeriod := strings.HasPrefix(periodStr, "0 ") || periodStr == "0"
var nextShowAt *time.Time
if !isZeroPeriod {
nextShowAt = calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.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, $5::INTERVAL, NULL, $6, 0, FALSE)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, nextShowAt}
} else {
insertSQL = `
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted)
VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, 0, FALSE)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue}
}
} 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 {
// Вычисляем next_show_at для задачи с repetition_period
periodStr := strings.TrimSpace(repetitionPeriod.String)
isZeroPeriod := strings.HasPrefix(periodStr, "0 ") || periodStr == "0"
var nextShowAt *time.Time
if !isZeroPeriod {
nextShowAt = calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, time.Now())
}
if nextShowAt != nil {
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, nextShowAt, taskID}
} else {
updateSQL = `
UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, next_show_at = NULL
WHERE id = $5
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, 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, 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()
nextShowAt := calculateNextShowAtFromRepetitionPeriod(repetitionPeriod.String, 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 {
_, 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",
})
}
// 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",
})
}