2025-12-29 20:01:55 +03:00
package main
import (
"bytes"
2026-01-01 18:21:18 +03:00
"context"
"crypto/rand"
2025-12-29 20:01:55 +03:00
"database/sql"
2026-01-01 18:21:18 +03:00
"encoding/base64"
2025-12-29 20:01:55 +03:00
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf16"
"github.com/go-telegram-bot-api/telegram-bot-api/v5"
2026-01-01 18:21:18 +03:00
"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/robfig/cron/v3"
2026-01-01 18:21:18 +03:00
"golang.org/x/crypto/bcrypt"
2025-12-29 20:01:55 +03:00
)
type Word struct {
ID int ` json:"id" `
Name string ` json:"name" `
Translation string ` json:"translation" `
Description string ` json:"description" `
Success int ` json:"success" `
Failure int ` json:"failure" `
LastSuccess * string ` json:"last_success_at,omitempty" `
LastFailure * string ` json:"last_failure_at,omitempty" `
}
type WordRequest struct {
Name string ` json:"name" `
Translation string ` json:"translation" `
Description string ` json:"description" `
DictionaryID * int ` json:"dictionary_id,omitempty" `
}
type WordsRequest struct {
Words [ ] WordRequest ` json:"words" `
}
type TestProgressUpdate struct {
ID int ` json:"id" `
Success int ` json:"success" `
Failure int ` json:"failure" `
LastSuccessAt * string ` json:"last_success_at,omitempty" `
LastFailureAt * string ` json:"last_failure_at,omitempty" `
}
type TestProgressRequest struct {
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" `
}
2025-12-31 19:11:28 +03:00
type TelegramChat struct {
ID int64 ` json:"id" `
}
2025-12-29 20:01:55 +03:00
type TelegramMessage struct {
Text string ` json:"text" `
Entities [ ] TelegramEntity ` json:"entities" `
2025-12-31 19:11:28 +03:00
Chat TelegramChat ` json:"chat" `
2025-12-29 20:01:55 +03:00
}
type TelegramWebhook struct {
Message TelegramMessage ` json:"message" `
}
// TelegramUpdate - структура для Telegram webhook (обычно это Update объект)
type TelegramUpdate struct {
2026-01-01 18:21:18 +03:00
UpdateID int ` json:"update_id" `
Message * TelegramMessage ` json:"message,omitempty" `
2025-12-31 19:39:01 +03:00
EditedMessage * TelegramMessage ` json:"edited_message,omitempty" `
2025-12-29 20:01:55 +03:00
}
2026-01-01 18:21:18 +03:00
// ============================================
// Auth types
// ============================================
type User struct {
ID int ` json:"id" `
Email string ` json:"email" `
Name * string ` json:"name,omitempty" `
PasswordHash string ` json:"-" `
CreatedAt time . Time ` json:"created_at" `
UpdatedAt time . Time ` json:"updated_at" `
IsActive bool ` json:"is_active" `
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
telegramChatID int64
2026-01-01 18:21:18 +03:00
jwtSecret [ ] byte
2025-12-29 20:01:55 +03:00
}
func setCORSHeaders ( w http . ResponseWriter ) {
w . Header ( ) . Set ( "Access-Control-Allow-Origin" , "*" )
w . Header ( ) . Set ( "Access-Control-Allow-Methods" , "GET, POST, PUT, DELETE, OPTIONS" )
2026-01-01 18:21:18 +03:00
w . Header ( ) . Set ( "Access-Control-Allow-Headers" , "Content-Type, Authorization" )
}
// ============================================
// Auth helper functions
// ============================================
func hashPassword ( password string ) ( string , error ) {
bytes , err := bcrypt . GenerateFromPassword ( [ ] byte ( password ) , bcrypt . DefaultCost )
return string ( bytes ) , err
}
func checkPasswordHash ( password , hash string ) bool {
err := bcrypt . CompareHashAndPassword ( [ ] byte ( hash ) , [ ] byte ( password ) )
return err == nil
}
func generateRefreshToken ( ) ( string , error ) {
b := make ( [ ] byte , 32 )
_ , err := rand . Read ( b )
if err != nil {
return "" , err
}
return base64 . URLEncoding . EncodeToString ( b ) , nil
}
2026-01-01 18:38:28 +03:00
// generateWebhookToken generates a unique token for webhook URL identification
func generateWebhookToken ( ) ( string , error ) {
b := make ( [ ] byte , 24 ) // 24 bytes = 32 chars in base64
_ , err := rand . Read ( b )
if err != nil {
return "" , err
}
return base64 . URLEncoding . EncodeToString ( b ) , nil
}
2026-01-01 18:21:18 +03:00
func ( a * App ) generateAccessToken ( userID int ) ( string , error ) {
claims := JWTClaims {
UserID : userID ,
RegisteredClaims : jwt . RegisteredClaims {
ExpiresAt : jwt . NewNumericDate ( time . Now ( ) . Add ( 15 * time . Minute ) ) ,
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 , time . Now ( ) . Add ( 7 * 24 * time . Hour ) )
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 : 900 , // 15 minutes
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 , time . Now ( ) . Add ( 7 * 24 * time . Hour ) )
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 : 900 ,
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
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 > 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 , time . Now ( ) . Add ( 7 * 24 * time . Hour ) )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( TokenResponse {
AccessToken : accessToken ,
RefreshToken : refreshToken ,
ExpiresIn : 900 ,
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" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
// Get dictionary_id from query parameter
dictionaryIDStr := r . URL . Query ( ) . Get ( "dictionary_id" )
var dictionaryID * int
if dictionaryIDStr != "" {
if id , err := strconv . Atoi ( dictionaryIDStr ) ; err == nil {
dictionaryID = & id
}
}
query := `
SELECT
w . id ,
w . name ,
w . translation ,
w . description ,
COALESCE ( p . success , 0 ) as success ,
COALESCE ( p . failure , 0 ) as failure ,
CASE WHEN p . last_success_at IS NOT NULL THEN p . last_success_at : : text ELSE NULL END as last_success_at ,
CASE WHEN p . last_failure_at IS NOT NULL THEN p . last_failure_at : : text ELSE NULL END as last_failure_at
FROM words w
2026-01-01 18:21:18 +03:00
JOIN dictionaries d ON w . dictionary_id = d . id
LEFT JOIN progress p ON w . id = p . word_id AND p . user_id = $ 1
WHERE d . user_id = $ 1 AND ( $ 2 : : INTEGER IS NULL OR w . dictionary_id = $ 2 )
2025-12-29 20:01:55 +03:00
ORDER BY w . id
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID , dictionaryID )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
defer rows . Close ( )
words := make ( [ ] Word , 0 )
for rows . Next ( ) {
var word Word
var lastSuccess , lastFailure sql . NullString
err := rows . Scan (
& word . ID ,
& word . Name ,
& word . Translation ,
& word . Description ,
& word . Success ,
& word . Failure ,
& lastSuccess ,
& lastFailure ,
)
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
if lastSuccess . Valid {
word . LastSuccess = & lastSuccess . String
}
if lastFailure . Valid {
word . LastFailure = & lastFailure . String
}
words = append ( words , word )
}
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( words )
}
func ( a * App ) addWordsHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
var req WordsRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
tx , err := a . DB . Begin ( )
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
defer tx . Rollback ( )
2026-01-01 18:21:18 +03:00
// Create default dictionary for user if needed
var defaultDictID int
err = tx . QueryRow ( `
SELECT id FROM dictionaries WHERE user_id = $ 1 AND id = 0
UNION ALL
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
err = tx . QueryRow ( `
INSERT INTO dictionaries ( name , user_id ) VALUES ( ' В с е слова ' , $ 1 ) RETURNING id
` , userID ) . Scan ( & defaultDictID )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
}
2025-12-29 20:01:55 +03:00
stmt , err := tx . Prepare ( `
INSERT INTO words ( name , translation , description , dictionary_id )
2026-01-01 18:21:18 +03:00
VALUES ( $ 1 , $ 2 , $ 3 , COALESCE ( $ 4 , $ 5 ) )
2025-12-29 20:01:55 +03:00
RETURNING id
` )
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
defer stmt . Close ( )
var addedCount int
for _ , wordReq := range req . Words {
var id int
2026-01-01 18:21:18 +03:00
dictionaryID := defaultDictID
2025-12-29 20:01:55 +03:00
if wordReq . DictionaryID != nil {
dictionaryID = * wordReq . DictionaryID
}
2026-01-01 18:21:18 +03:00
err := stmt . QueryRow ( wordReq . Name , wordReq . Translation , wordReq . Description , wordReq . DictionaryID , dictionaryID ) . Scan ( & id )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
addedCount ++
}
if err := tx . Commit ( ) ; err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"message" : fmt . Sprintf ( "Added %d words" , addedCount ) ,
"added" : addedCount ,
} )
}
func ( a * App ) getTestWordsHandler ( w http . ResponseWriter , r * http . Request ) {
log . Printf ( "getTestWordsHandler called: %s %s" , r . Method , r . URL . Path )
setCORSHeaders ( w )
if r . Method == "OPTIONS" {
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
// Get config_id from query parameter (required)
configIDStr := r . URL . Query ( ) . Get ( "config_id" )
if configIDStr == "" {
sendErrorWithCORS ( w , "config_id parameter is required" , http . StatusBadRequest )
return
}
configID , err := strconv . Atoi ( configIDStr )
if err != nil {
sendErrorWithCORS ( w , "invalid config_id parameter" , http . StatusBadRequest )
return
}
2026-01-01 18:21:18 +03:00
// Get words_count from config (verify ownership)
2025-12-29 20:01:55 +03:00
var wordsCount int
2026-01-01 18:21:18 +03:00
err = a . DB . QueryRow ( "SELECT words_count FROM configs WHERE id = $1 AND user_id = $2" , configID , userID ) . Scan ( & wordsCount )
2025-12-29 20:01:55 +03:00
if err != nil {
if err == sql . ErrNoRows {
sendErrorWithCORS ( w , "config not found" , http . StatusNotFound )
return
}
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
// Get dictionary IDs for this config
var dictionaryIDs [ ] int
dictQuery := `
SELECT dictionary_id
FROM config_dictionaries
WHERE config_id = $ 1
`
dictRows , err := a . DB . Query ( dictQuery , configID )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
defer dictRows . Close ( )
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
`
2026-01-01 18:21:18 +03:00
baseFrom := fmt . Sprintf ( `
2025-12-29 20:01:55 +03:00
FROM words w
2026-01-01 18:21:18 +03:00
JOIN dictionaries d ON w . dictionary_id = d . id AND d . user_id = % d
LEFT JOIN progress p ON w . id = p . word_id AND p . user_id = % d
WHERE ` , userID , userID ) + dictFilter
2025-12-29 20:01:55 +03:00
// Group 1: success <= 3, sorted by success ASC, then last_success_at ASC (NULL first)
group1Query := `
SELECT ` + baseSelect + `
` + baseFrom + `
AND COALESCE ( p . success , 0 ) <= 3
ORDER BY
COALESCE ( p . success , 0 ) ASC ,
CASE WHEN p . last_success_at IS NULL THEN 0 ELSE 1 END ,
p . last_success_at ASC
LIMIT $ ` + fmt . Sprintf ( "%d" , len ( dictArgs ) + 1 )
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: (failure - success) >= 5, sorted by (failure - success) DESC, then last_success_at ASC (NULL first)
// 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 + `
AND ( COALESCE ( p . failure , 0 ) - COALESCE ( p . success , 0 ) ) >= 5
` + group2Exclude + `
ORDER BY
( COALESCE ( p . failure , 0 ) - COALESCE ( p . success , 0 ) ) DESC ,
CASE WHEN p . last_success_at IS NULL THEN 0 ELSE 1 END ,
p . last_success_at ASC
LIMIT $ ` + fmt . Sprintf ( "%d" , len ( group2Args ) + 1 )
group2Args = append ( group2Args , group2Count * 2 ) // Get more to ensure uniqueness
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 ( ) && len ( group2Words ) < group2Count {
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
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
var req TestProgressRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
log . Printf ( "Error decoding request: %v" , err )
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
return
}
2026-01-01 18:21:18 +03:00
log . Printf ( "Received %d word updates, config_id: %v, user_id: %d" , len ( req . Words ) , req . ConfigID , userID )
2025-12-29 20:01:55 +03:00
tx , err := a . DB . Begin ( )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
defer tx . Rollback ( )
2026-01-01 18:21:18 +03:00
// Create unique constraint for (word_id, user_id) if not exists
tx . Exec ( "CREATE UNIQUE INDEX IF NOT EXISTS progress_word_user_unique ON progress(word_id, user_id)" )
2025-12-29 20:01:55 +03:00
stmt , err := tx . Prepare ( `
2026-01-01 18:21:18 +03:00
INSERT INTO progress ( word_id , user_id , success , failure , last_success_at , last_failure_at )
VALUES ( $ 1 , $ 2 , $ 3 , $ 4 , $ 5 , $ 6 )
ON CONFLICT ( word_id , user_id )
2025-12-29 20:01:55 +03:00
DO UPDATE SET
success = EXCLUDED . success ,
failure = EXCLUDED . failure ,
last_success_at = COALESCE ( EXCLUDED . last_success_at , progress . last_success_at ) ,
last_failure_at = COALESCE ( EXCLUDED . last_failure_at , progress . last_failure_at )
` )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
defer stmt . Close ( )
for _ , wordUpdate := range req . Words {
// Convert pointers to values for logging
lastSuccessStr := "nil"
if wordUpdate . LastSuccessAt != nil {
lastSuccessStr = * wordUpdate . LastSuccessAt
}
lastFailureStr := "nil"
if wordUpdate . LastFailureAt != nil {
lastFailureStr = * wordUpdate . LastFailureAt
}
log . Printf ( "Updating word %d: success=%d, failure=%d, last_success_at=%s, last_failure_at=%s" ,
wordUpdate . ID , wordUpdate . Success , wordUpdate . Failure , lastSuccessStr , lastFailureStr )
// 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 ,
2026-01-01 18:21:18 +03:00
userID ,
2025-12-29 20:01:55 +03:00
wordUpdate . Success ,
wordUpdate . Failure ,
lastSuccess ,
lastFailure ,
)
if err != nil {
log . Printf ( "Error executing update for word %d: %v" , wordUpdate . ID , err )
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
}
if err := tx . Commit ( ) ; err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
// 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)
2026-01-01 18:21:18 +03:00
_ , 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" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
query := `
SELECT id , name , words_count , max_cards , try_message
FROM configs
2026-01-01 18:21:18 +03:00
WHERE user_id = $ 1
2025-12-29 20:01:55 +03:00
ORDER BY id
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
defer rows . Close ( )
configs := make ( [ ] Config , 0 )
for rows . Next ( ) {
var config Config
var maxCards sql . NullInt64
err := rows . Scan (
& config . ID ,
& config . Name ,
& config . WordsCount ,
& maxCards ,
& config . TryMessage ,
)
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
if maxCards . Valid {
maxCardsVal := int ( maxCards . Int64 )
config . MaxCards = & maxCardsVal
}
configs = append ( configs , config )
}
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( configs )
}
func ( a * App ) getDictionariesHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
query := `
SELECT
d . id ,
d . name ,
COALESCE ( COUNT ( w . id ) , 0 ) as words_count
FROM dictionaries d
LEFT JOIN words w ON d . id = w . dictionary_id
2026-01-01 18:21:18 +03:00
WHERE d . user_id = $ 1
2025-12-29 20:01:55 +03:00
GROUP BY d . id , d . name
ORDER BY d . id
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
defer rows . Close ( )
dictionaries := make ( [ ] Dictionary , 0 )
for rows . Next ( ) {
var dict Dictionary
err := rows . Scan (
& dict . ID ,
& dict . Name ,
& dict . WordsCount ,
)
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
dictionaries = append ( dictionaries , dict )
}
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( dictionaries )
}
func ( a * App ) addDictionaryHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
var req DictionaryRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
if req . Name == "" {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Имя словаря обязательно" , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
var id int
err := a . DB . QueryRow ( `
2026-01-01 18:21:18 +03:00
INSERT INTO dictionaries ( name , user_id )
VALUES ( $ 1 , $ 2 )
2025-12-29 20:01:55 +03:00
RETURNING id
2026-01-01 18:21:18 +03:00
` , req . Name , userID ) . Scan ( & id )
2025-12-29 20:01:55 +03:00
if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Header ( ) . Set ( "Access-Control-Allow-Origin" , "*" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"id" : id ,
"name" : req . Name ,
} )
}
func ( a * App ) updateDictionaryHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
vars := mux . Vars ( r )
dictionaryID := vars [ "id" ]
var req DictionaryRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
if req . Name == "" {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Имя словаря обязательно" , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
result , err := a . DB . Exec ( `
UPDATE dictionaries
SET name = $ 1
2026-01-01 18:21:18 +03:00
WHERE id = $ 2 AND user_id = $ 3
` , req . Name , dictionaryID , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
rowsAffected , err := result . RowsAffected ( )
if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
if rowsAffected == 0 {
http . Error ( w , "Dictionary not found" , http . StatusNotFound )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Header ( ) . Set ( "Access-Control-Allow-Origin" , "*" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"message" : "Dictionary updated successfully" ,
} )
}
func ( a * App ) deleteDictionaryHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
vars := mux . Vars ( r )
dictionaryID := vars [ "id" ]
// Prevent deletion of default dictionary (id = 0)
if dictionaryID == "0" {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Cannot delete default dictionary" , http . StatusBadRequest )
return
}
// Verify ownership
var ownerID int
err := a . DB . QueryRow ( "SELECT user_id FROM dictionaries WHERE id = $1" , dictionaryID ) . Scan ( & ownerID )
if err != nil || ownerID != userID {
sendErrorWithCORS ( w , "Dictionary not found" , http . StatusNotFound )
2025-12-29 20:01:55 +03:00
return
}
tx , err := a . DB . Begin ( )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
defer tx . Rollback ( )
// Delete all words from this dictionary (progress will be deleted automatically due to CASCADE)
_ , err = tx . Exec ( `
DELETE FROM words
WHERE dictionary_id = $ 1
` , dictionaryID )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
// Delete all config-dictionary associations (will be deleted automatically due to CASCADE, but doing explicitly for clarity)
_ , err = tx . Exec ( `
DELETE FROM config_dictionaries
WHERE dictionary_id = $ 1
` , dictionaryID )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
// Delete the dictionary
result , err := tx . Exec ( "DELETE FROM dictionaries WHERE id = $1" , dictionaryID )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
rowsAffected , err := result . RowsAffected ( )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
if rowsAffected == 0 {
sendErrorWithCORS ( w , "Dictionary not found" , http . StatusNotFound )
return
}
if err := tx . Commit ( ) ; err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"message" : "Dictionary deleted successfully. All words and configuration associations have been deleted." ,
} )
}
func ( a * App ) getConfigDictionariesHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
w . Header ( ) . Set ( "Access-Control-Allow-Origin" , "*" )
w . Header ( ) . Set ( "Access-Control-Allow-Methods" , "GET, OPTIONS" )
w . Header ( ) . Set ( "Access-Control-Allow-Headers" , "Content-Type" )
w . WriteHeader ( http . StatusOK )
return
}
vars := mux . Vars ( r )
configID := vars [ "id" ]
query := `
SELECT dictionary_id
FROM config_dictionaries
WHERE config_id = $ 1
ORDER BY dictionary_id
`
rows , err := a . DB . Query ( query , configID )
if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
defer rows . Close ( )
dictionaryIDs := make ( [ ] int , 0 )
for rows . Next ( ) {
var dictID int
err := rows . Scan ( & dictID )
if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
dictionaryIDs = append ( dictionaryIDs , dictID )
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Header ( ) . Set ( "Access-Control-Allow-Origin" , "*" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"dictionary_ids" : dictionaryIDs ,
} )
}
func ( a * App ) getTestConfigsAndDictionariesHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
userID , ok := getUserIDFromContext ( r )
if ! ok {
log . Printf ( "getTestConfigsAndDictionariesHandler: Unauthorized request" )
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
log . Printf ( "getTestConfigsAndDictionariesHandler called, user: %d" , userID )
2025-12-29 20:01:55 +03:00
// Get configs
configsQuery := `
SELECT id , name , words_count , max_cards , try_message
FROM configs
2026-01-01 18:21:18 +03:00
WHERE user_id = $ 1
2025-12-29 20:01:55 +03:00
ORDER BY id
`
2026-01-01 18:21:18 +03:00
configsRows , err := a . DB . Query ( configsQuery , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
defer configsRows . Close ( )
configs := make ( [ ] Config , 0 )
for configsRows . Next ( ) {
var config Config
var maxCards sql . NullInt64
err := configsRows . Scan (
& config . ID ,
& config . 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
2026-01-01 18:21:18 +03:00
WHERE d . user_id = $ 1
2025-12-29 20:01:55 +03:00
GROUP BY d . id , d . name
ORDER BY d . id
`
2026-01-01 18:21:18 +03:00
dictsRows , err := a . DB . Query ( dictsQuery , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
defer dictsRows . Close ( )
dictionaries := make ( [ ] Dictionary , 0 )
for dictsRows . Next ( ) {
var dict Dictionary
err := dictsRows . Scan (
& dict . ID ,
& dict . Name ,
& dict . WordsCount ,
)
if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
dictionaries = append ( dictionaries , dict )
}
response := TestConfigsAndDictionariesResponse {
Configs : configs ,
Dictionaries : dictionaries ,
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . Header ( ) . Set ( "Access-Control-Allow-Origin" , "*" )
json . NewEncoder ( w ) . Encode ( response )
}
func ( a * App ) addConfigHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
2026-01-01 18:21:18 +03:00
setCORSHeaders ( w )
2025-12-29 20:01:55 +03:00
w . WriteHeader ( http . StatusOK )
return
}
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
var req ConfigRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
if req . Name == "" {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Имя обязательно для заполнения" , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
if req . WordsCount <= 0 {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Количество слов должно быть больше 0" , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
tx , err := a . DB . Begin ( )
if err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
2025-12-29 20:01:55 +03:00
return
}
defer tx . Rollback ( )
var id int
err = tx . QueryRow ( `
2026-01-01 18:21:18 +03:00
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
2026-01-01 18:21:18 +03:00
` , 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 )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
vars := mux . Vars ( r )
configID := vars [ "id" ]
2026-01-01 18:21:18 +03:00
// Verify ownership
var ownerID int
err := a . DB . QueryRow ( "SELECT user_id FROM configs WHERE id = $1" , configID ) . Scan ( & ownerID )
if err != nil || ownerID != userID {
sendErrorWithCORS ( w , "Config not found" , http . StatusNotFound )
return
}
2025-12-29 20:01:55 +03:00
var req ConfigRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , err . Error ( ) , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
if req . Name == "" {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Имя обязательно для заполнения" , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
if req . WordsCount <= 0 {
2026-01-01 18:21:18 +03:00
sendErrorWithCORS ( w , "Количество слов должно быть больше 0" , http . StatusBadRequest )
2025-12-29 20:01:55 +03:00
return
}
tx , err := a . DB . Begin ( )
if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
defer tx . Rollback ( )
result , err := tx . Exec ( `
UPDATE configs
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 )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
vars := mux . Vars ( r )
configID := vars [ "id" ]
2026-01-01 18:21:18 +03:00
result , err := a . DB . Exec ( "DELETE FROM configs WHERE id = $1 AND user_id = $2" , configID , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
rowsAffected , err := result . RowsAffected ( )
if err != nil {
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
if rowsAffected == 0 {
sendErrorWithCORS ( w , "Config not found" , http . StatusNotFound )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"message" : "Config deleted successfully" ,
} )
}
func ( a * App ) getWeeklyStatsHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
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 ,
2025-12-30 18:27:12 +03:00
COALESCE ( wg . priority , p . priority ) AS priority
2025-12-29 20:01:55 +03:00
FROM
2025-12-30 18:27:12 +03:00
projects p
LEFT JOIN
weekly_goals wg ON wg . project_id = p . id
AND wg . goal_year = EXTRACT ( ISOYEAR FROM CURRENT_DATE ) : : INTEGER
AND wg . goal_week = EXTRACT ( WEEK FROM CURRENT_DATE ) : : INTEGER
2025-12-29 20:01:55 +03:00
LEFT JOIN
weekly_report_mv wr
2025-12-30 18:27:12 +03:00
ON p . id = wr . project_id
AND EXTRACT ( ISOYEAR FROM CURRENT_DATE ) : : INTEGER = wr . report_year
AND EXTRACT ( WEEK FROM CURRENT_DATE ) : : INTEGER = wr . report_week
2025-12-29 20:01:55 +03:00
WHERE
2026-01-01 18:21:18 +03:00
p . deleted = FALSE AND p . user_id = $ 1
2025-12-29 20:01:55 +03:00
ORDER BY
total_score DESC
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "Error querying weekly stats: %v" , err )
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
defer rows . Close ( )
projects := make ( [ ] WeeklyProjectStats , 0 )
// Группы для расчета среднего по priority
groups := make ( map [ int ] [ ] float64 )
for rows . Next ( ) {
var project WeeklyProjectStats
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 )
}
// Находим среднее внутри каждой группы
groupAverages := make ( [ ] float64 , 0 )
2025-12-30 18:27:12 +03:00
for priorityVal , scores := range groups {
2025-12-29 20:01:55 +03:00
if len ( scores ) > 0 {
2025-12-30 18:27:12 +03:00
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 / math . Floor ( 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 )
2025-12-29 20:01:55 +03:00
}
2025-12-30 18:27:12 +03:00
2025-12-29 20:01:55 +03:00
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
}
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
}
2026-01-01 18:21:18 +03:00
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" }
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)" )
2026-01-01 18:38:28 +03:00
// Add webhook_token to telegram_integrations for URL-based user identification
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" )
2026-01-01 18:21:18 +03:00
// Clean up expired refresh tokens
a . DB . Exec ( "DELETE FROM refresh_tokens WHERE expires_at < NOW()" )
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 )
}
2025-12-29 21:31:43 +03:00
// Добавляем колонку 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 ,
2025-12-29 20:58:34 +03:00
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
2025-12-29 21:31:43 +03:00
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 )
}
2025-12-31 19:11:28 +03:00
// Создаем таблицу 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 )
}
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 ,
2025-12-30 18:27:12 +03:00
COALESCE ( wg . priority , p . 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 )
}
// Находим среднее внутри каждой группы
groupAverages := make ( [ ] float64 , 0 )
2025-12-30 18:27:12 +03:00
for priorityVal , scores := range groups {
2025-12-29 20:01:55 +03:00
if len ( scores ) > 0 {
2025-12-30 18:27:12 +03:00
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 / math . Floor ( 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 )
2025-12-29 20:01:55 +03:00
}
2025-12-30 18:27:12 +03:00
2025-12-29 20:01:55 +03:00
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
}
response := WeeklyStatsResponse {
Total : total ,
Projects : projects ,
}
return & response , nil
}
// formatDailyReport форматирует данные проектов в сообщение для Telegram
// Формат аналогичен JS коду из n8n
func ( a * App ) formatDailyReport ( data * WeeklyStatsResponse ) string {
if data == nil || len ( data . Projects ) == 0 {
return ""
}
// Заголовок сообщения
markdownMessage := "*📈 Отчет по Score и Целям за текущую неделю:*\n\n"
// Простой вывод списка проектов
for _ , item := range data . Projects {
projectName := item . ProjectName
if projectName == "" {
projectName = "Без названия"
}
actualScore := item . TotalScore
minGoal := item . MinGoalScore
var maxGoal float64
hasMaxGoal := false
if item . MaxGoalScore != nil {
maxGoal = * item . MaxGoalScore
hasMaxGoal = true
}
// Форматирование Score (+/-)
scoreFormatted := ""
if actualScore >= 0 {
scoreFormatted = fmt . Sprintf ( "+%.2f" , actualScore )
} else {
scoreFormatted = fmt . Sprintf ( "%.2f" , actualScore )
}
// Форматирование текста целей
// Проверяем, что minGoal валиден (не NaN, как в JS коде: !isNaN(minGoal))
goalText := ""
if ! math . IsNaN ( minGoal ) {
if hasMaxGoal && ! math . IsNaN ( maxGoal ) {
goalText = fmt . Sprintf ( " (Цель: %.1f– %.1f)" , minGoal , maxGoal )
} else {
goalText = fmt . Sprintf ( " (Цель: мин. %.1f)" , minGoal )
}
}
// Собираем строку: Проект: +Score (Цели)
markdownMessage += fmt . Sprintf ( "*%s*: %s%s\n" , projectName , scoreFormatted , goalText )
}
// Выводим итоговый total из корня JSON
if data . Total != nil {
markdownMessage += "\n---\n"
markdownMessage += fmt . Sprintf ( "*Общее выполнение целей*: %.1f%%" , * data . Total )
}
return markdownMessage
}
// sendDailyReport получает данные, форматирует и отправляет отчет в Telegram
func ( a * App ) sendDailyReport ( ) error {
log . Printf ( "Scheduled task: Sending daily report" )
// Получаем данные
data , err := a . getWeeklyStatsData ( )
if err != nil {
log . Printf ( "Error getting weekly stats data: %v" , err )
return fmt . Errorf ( "error getting weekly stats data: %w" , err )
}
// Форматируем сообщение
message := a . formatDailyReport ( data )
if message == "" {
log . Println ( "No data to send in daily report" )
return nil
}
// Отправляем сообщение в Telegram (без попытки разбирать на nodes)
a . sendTelegramMessage ( message )
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
// Планировщик будет работать в фоновом режиме
}
func main ( ) {
// Загружаем переменные окружения из .env файла (если существует)
// Сначала пробуем загрузить из корня проекта, затем из текущей директории
// Игнорируем ошибку, если файл не найден
godotenv . Load ( "../.env" ) // Пробуем корневой .env
godotenv . Load ( ".env" ) // Пробуем локальный .env
dbHost := getEnv ( "DB_HOST" , "localhost" )
dbPort := getEnv ( "DB_PORT" , "5432" )
dbUser := getEnv ( "DB_USER" , "playeng" )
dbPassword := getEnv ( "DB_PASSWORD" , "playeng" )
dbName := getEnv ( "DB_NAME" , "playeng" )
// Логируем параметры подключения к БД (без пароля)
log . Printf ( "Database connection parameters: host=%s port=%s user=%s dbname=%s" , dbHost , dbPort , dbUser , dbName )
dsn := fmt . Sprintf ( "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable" ,
dbHost , dbPort , dbUser , dbPassword , dbName )
var db * sql . DB
var err error
// Retry connection
for i := 0 ; i < 10 ; i ++ {
db , err = sql . Open ( "postgres" , dsn )
if err == nil {
err = db . Ping ( )
if err == nil {
break
}
}
if i < 9 {
time . Sleep ( 2 * time . Second )
}
}
if err != nil {
log . Fatal ( "Failed to connect to database:" , err )
}
log . Printf ( "Successfully connected to database: %s@%s:%s/%s" , dbUser , dbHost , dbPort , dbName )
defer db . Close ( )
2025-12-31 19:11:28 +03:00
// Telegram бот теперь загружается из БД при необходимости
// Webhook будет настроен автоматически при сохранении bot token через UI
2025-12-29 20:01:55 +03:00
2026-01-01 18:21:18 +03:00
// JWT secret from env or generate random
jwtSecret := getEnv ( "JWT_SECRET" , "" )
if jwtSecret == "" {
// Generate random secret if not provided (not recommended for production)
b := make ( [ ] byte , 32 )
rand . Read ( b )
jwtSecret = base64 . StdEncoding . EncodeToString ( b )
log . Printf ( "WARNING: JWT_SECRET not set, using randomly generated secret. Set JWT_SECRET env var for production." )
}
2025-12-31 19:11:28 +03:00
app := & App {
DB : db ,
lastWebhookTime : make ( map [ int ] time . Time ) ,
telegramBot : nil , // Больше не используем глобальный bot
telegramChatID : 0 , // Больше не используем глобальный chat_id
2026-01-01 18:21:18 +03:00
jwtSecret : [ ] byte ( jwtSecret ) ,
2025-12-31 19:11:28 +03:00
}
2026-01-01 18:38:28 +03:00
// Пытаемся настроить webhook автоматически при старте для всех пользователей с bot_token
2025-12-31 19:11:28 +03:00
webhookBaseURL := getEnv ( "WEBHOOK_BASE_URL" , "" )
if webhookBaseURL != "" {
2026-01-01 18:38:28 +03:00
log . Printf ( "Setting up Telegram webhooks for all users at startup..." )
rows , err := app . DB . Query ( `
SELECT user_id , bot_token , webhook_token
FROM telegram_integrations
WHERE bot_token IS NOT NULL
AND bot_token != ' '
AND webhook_token IS NOT NULL
AND webhook_token != ' '
AND user_id IS NOT NULL
` )
if err != nil {
log . Printf ( "Warning: Failed to query telegram integrations at startup: %v" , err )
} else {
defer rows . Close ( )
configuredCount := 0
for rows . Next ( ) {
var userID int
var botToken , webhookToken string
if err := rows . Scan ( & userID , & botToken , & webhookToken ) ; err != nil {
log . Printf ( "Warning: Failed to scan telegram integration: %v" , err )
continue
}
webhookURL := strings . TrimRight ( webhookBaseURL , "/" ) + "/webhook/telegram/" + webhookToken
log . Printf ( "Setting up Telegram webhook for user_id=%d: URL=%s" , userID , webhookURL )
if err := setupTelegramWebhook ( botToken , webhookURL ) ; err != nil {
log . Printf ( "Warning: Failed to setup Telegram webhook for user_id=%d: %v" , userID , err )
} else {
log . Printf ( "SUCCESS: Telegram webhook configured for user_id=%d: %s" , userID , webhookURL )
configuredCount ++
}
}
if configuredCount > 0 {
log . Printf ( "Telegram webhooks configured for %d user(s) at startup" , configuredCount )
2025-12-29 20:01:55 +03:00
} else {
2026-01-01 18:38:28 +03:00
log . Printf ( "No Telegram integrations found with bot_token and webhook_token. Webhooks will be configured when users save bot tokens." )
2025-12-29 20:01:55 +03:00
}
}
2025-12-31 19:39:01 +03:00
} else {
log . Printf ( "WEBHOOK_BASE_URL not set. Webhook will be configured when user saves bot token." )
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" )
2026-01-01 18:21:18 +03:00
// Инициализируем таблицы для авторизации
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 ( )
2026-01-01 18:21:18 +03:00
// Public auth routes (no authentication required)
r . HandleFunc ( "/api/auth/register" , app . registerHandler ) . Methods ( "POST" , "OPTIONS" )
r . HandleFunc ( "/api/auth/login" , app . loginHandler ) . Methods ( "POST" , "OPTIONS" )
r . HandleFunc ( "/api/auth/refresh" , app . refreshTokenHandler ) . Methods ( "POST" , "OPTIONS" )
// Webhooks - no auth (external services)
2025-12-29 20:01:55 +03:00
r . HandleFunc ( "/webhook/message/post" , app . messagePostHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-01 18:38:28 +03:00
r . HandleFunc ( "/webhook/todoist/{token}" , app . todoistWebhookHandler ) . Methods ( "POST" , "OPTIONS" )
r . HandleFunc ( "/webhook/telegram/{token}" , app . telegramWebhookHandler ) . Methods ( "POST" , "OPTIONS" )
2026-01-01 18:21:18 +03:00
// 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" )
2026-01-01 18:21:18 +03:00
// 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 ( "/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" )
protected . HandleFunc ( "/api/integrations/todoist/webhook-url" , app . getTodoistWebhookURLHandler ) . Methods ( "GET" , "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 )
2026-01-01 18:21:18 +03:00
log . Printf ( "Registered public routes: /api/auth/register, /api/auth/login, /api/auth/refresh, webhooks" )
log . Printf ( "All other routes require authentication via Bearer token" )
2025-12-29 20:01:55 +03:00
log . Printf ( "Admin panel available at: http://localhost:%s/admin.html" , port )
log . Fatal ( http . ListenAndServe ( ":" + port , r ) )
}
func getEnv ( key , defaultValue string ) string {
if value := os . Getenv ( key ) ; value != "" {
return value
}
return defaultValue
}
// getMapKeys возвращает список ключей из map
func getMapKeys ( m map [ string ] interface { } ) [ ] string {
keys := make ( [ ] string , 0 , len ( m ) )
for k := range m {
keys = append ( keys , k )
}
return keys
}
// setupTelegramWebhook настраивает webhook для Telegram бота
func setupTelegramWebhook ( botToken , webhookURL string ) error {
apiURL := fmt . Sprintf ( "https://api.telegram.org/bot%s/setWebhook" , botToken )
2025-12-31 19:39:01 +03:00
log . Printf ( "Setting up Telegram webhook: apiURL=%s, webhookURL=%s" , apiURL , webhookURL )
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 {
2025-12-31 19:39:01 +03:00
log . Printf ( "ERROR: Failed to send webhook setup request: %v" , err )
2025-12-29 20:01:55 +03:00
return fmt . Errorf ( "failed to send webhook setup request: %w" , err )
}
defer resp . Body . Close ( )
2025-12-31 19:39:01 +03:00
bodyBytes , _ := io . ReadAll ( resp . Body )
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 ) )
}
var result map [ string ] interface { }
if err := json . NewDecoder ( resp . Body ) . Decode ( & result ) ; err != nil {
return fmt . Errorf ( "failed to decode response: %w" , err )
}
if ok , _ := result [ "ok" ] . ( bool ) ; ! ok {
description , _ := result [ "description" ] . ( string )
return fmt . Errorf ( "telegram API returned error: %s" , description )
}
return nil
}
// Вспомогательные функции для расчетов
func min ( a , b float64 ) float64 {
if a < b {
return a
}
return b
}
func max ( a , b float64 ) float64 {
if a > b {
return a
}
return b
}
func roundToTwoDecimals ( val float64 ) float64 {
return float64 ( int ( val * 100 + 0.5 ) ) / 100.0
}
func roundToFourDecimals ( val float64 ) float64 {
return float64 ( int ( val * 10000 + 0.5 ) ) / 10000.0
}
2025-12-31 19:11:28 +03:00
// TelegramIntegration представляет запись из таблицы telegram_integrations
type TelegramIntegration struct {
2026-01-01 18:38:28 +03:00
ID int ` json:"id" `
ChatID * string ` json:"chat_id" `
BotToken * string ` json:"bot_token" `
WebhookToken * string ` json:"webhook_token" `
2025-12-31 19:11:28 +03:00
}
// getTelegramIntegration получает telegram интеграцию из БД
2026-01-01 18:21:18 +03:00
// getTelegramIntegrationForUser gets telegram integration for specific user
func ( a * App ) getTelegramIntegrationForUser ( userID int ) ( * TelegramIntegration , error ) {
var integration TelegramIntegration
2026-01-01 18:38:28 +03:00
var chatID , botToken , webhookToken sql . NullString
2026-01-01 18:21:18 +03:00
err := a . DB . QueryRow ( `
2026-01-01 18:38:28 +03:00
SELECT id , chat_id , bot_token , webhook_token
2026-01-01 18:21:18 +03:00
FROM telegram_integrations
WHERE user_id = $ 1
ORDER BY id DESC
LIMIT 1
2026-01-01 18:38:28 +03:00
` , userID ) . Scan ( & integration . ID , & chatID , & botToken , & webhookToken )
2026-01-01 18:21:18 +03:00
if err == sql . ErrNoRows {
2026-01-01 18:38:28 +03:00
// Если записи нет, создаем новую для этого пользователя с webhook токеном
webhookToken , err := generateWebhookToken ( )
if err != nil {
return nil , fmt . Errorf ( "failed to generate webhook token: %w" , err )
}
2026-01-01 18:21:18 +03:00
err = a . DB . QueryRow ( `
2026-01-01 18:38:28 +03:00
INSERT INTO telegram_integrations ( chat_id , bot_token , user_id , webhook_token )
VALUES ( NULL , NULL , $ 1 , $ 2 )
2026-01-01 18:21:18 +03:00
RETURNING id
2026-01-01 18:38:28 +03:00
` , userID , webhookToken ) . Scan ( & integration . ID )
2026-01-01 18:21:18 +03:00
if err != nil {
return nil , fmt . Errorf ( "failed to create telegram integration: %w" , err )
}
2026-01-01 18:38:28 +03:00
integration . WebhookToken = & webhookToken
2026-01-01 18:21:18 +03:00
return & integration , nil
} else if err != nil {
return nil , fmt . Errorf ( "failed to get telegram integration: %w" , err )
}
if chatID . Valid {
integration . ChatID = & chatID . String
}
if botToken . Valid {
integration . BotToken = & botToken . String
}
2026-01-01 18:38:28 +03:00
if webhookToken . Valid {
integration . WebhookToken = & webhookToken . String
} else {
// Если токена нет, генерируем е г о
newToken , err := generateWebhookToken ( )
if err != nil {
return nil , fmt . Errorf ( "failed to generate webhook token: %w" , err )
}
_ , err = a . DB . Exec ( `
UPDATE telegram_integrations
SET webhook_token = $ 1
WHERE id = $ 2
` , newToken , integration . ID )
if err != nil {
return nil , fmt . Errorf ( "failed to update webhook token: %w" , err )
}
integration . WebhookToken = & newToken
}
2026-01-01 18:21:18 +03:00
return & integration , nil
}
2025-12-31 19:11:28 +03:00
func ( a * App ) getTelegramIntegration ( ) ( * TelegramIntegration , error ) {
var integration TelegramIntegration
var chatID , botToken sql . NullString
err := a . DB . QueryRow ( `
SELECT id , chat_id , bot_token
FROM telegram_integrations
ORDER BY id DESC
LIMIT 1
` ) . Scan ( & integration . ID , & chatID , & botToken )
if err == sql . ErrNoRows {
// Если записи нет, создаем новую
_ , err = a . DB . Exec ( `
INSERT INTO telegram_integrations ( chat_id , bot_token )
VALUES ( NULL , NULL )
` )
if err != nil {
return nil , fmt . Errorf ( "failed to create telegram integration: %w" , err )
}
// Повторно получаем созданную запись
err = a . DB . QueryRow ( `
SELECT id , chat_id , bot_token
FROM telegram_integrations
ORDER BY id DESC
LIMIT 1
` ) . Scan ( & integration . ID , & chatID , & botToken )
if err != nil {
return nil , fmt . Errorf ( "failed to get created telegram integration: %w" , err )
}
} else if err != nil {
return nil , fmt . Errorf ( "failed to get telegram integration: %w" , err )
}
if chatID . Valid {
integration . ChatID = & chatID . String
}
if botToken . Valid {
integration . BotToken = & botToken . String
}
return & integration , nil
}
// saveTelegramBotToken сохраняет bot token в БД
func ( a * App ) saveTelegramBotToken ( botToken string ) error {
// Проверяем, есть ли уже запись
integration , err := a . getTelegramIntegration ( )
if err != nil {
// Если записи нет, создаем новую
_ , err = a . DB . Exec ( `
INSERT INTO telegram_integrations ( bot_token , chat_id )
VALUES ( $ 1 , NULL )
` , botToken )
if err != nil {
return fmt . Errorf ( "failed to create telegram bot token: %w" , err )
}
} else {
// Обновляем существующую запись
_ , err = a . DB . Exec ( `
UPDATE telegram_integrations
SET bot_token = $ 1
WHERE id = $ 2
` , botToken , integration . ID )
if err != nil {
return fmt . Errorf ( "failed to update telegram bot token: %w" , err )
}
}
return nil
}
2026-01-01 18:21:18 +03:00
func ( a * App ) saveTelegramBotTokenForUser ( botToken string , userID int ) error {
// Проверяем, есть ли уже запись для этого пользователя
integration , err := a . getTelegramIntegrationForUser ( userID )
if err != nil {
2026-01-01 18:38:28 +03:00
// Если записи нет, создаем новую с webhook токеном
webhookToken , err := generateWebhookToken ( )
if err != nil {
return fmt . Errorf ( "failed to generate webhook token: %w" , err )
}
2026-01-01 18:21:18 +03:00
_ , err = a . DB . Exec ( `
2026-01-01 18:38:28 +03:00
INSERT INTO telegram_integrations ( bot_token , chat_id , user_id , webhook_token )
VALUES ( $ 1 , NULL , $ 2 , $ 3 )
` , botToken , userID , webhookToken )
2026-01-01 18:21:18 +03:00
if err != nil {
return fmt . Errorf ( "failed to create telegram bot token: %w" , err )
}
} else {
// Обновляем существующую запись
_ , err = a . DB . Exec ( `
UPDATE telegram_integrations
SET bot_token = $ 1
WHERE id = $ 2 AND user_id = $ 3
` , botToken , integration . ID , userID )
if err != nil {
return fmt . Errorf ( "failed to update telegram bot token: %w" , err )
}
2026-01-01 18:38:28 +03:00
// Убеждаемся, что webhook_token есть
if integration . WebhookToken == nil || * integration . WebhookToken == "" {
webhookToken , err := generateWebhookToken ( )
if err != nil {
return fmt . Errorf ( "failed to generate webhook token: %w" , err )
}
_ , err = a . DB . Exec ( `
UPDATE telegram_integrations
SET webhook_token = $ 1
WHERE id = $ 2
` , webhookToken , integration . ID )
if err != nil {
return fmt . Errorf ( "failed to update webhook token: %w" , err )
}
}
2026-01-01 18:21:18 +03:00
}
return nil
}
2025-12-31 19:11:28 +03:00
// saveTelegramChatID сохраняет chat_id в БД
func ( a * App ) saveTelegramChatID ( chatID string ) error {
// Получаем текущую интеграцию
integration , err := a . getTelegramIntegration ( )
if err != nil {
return fmt . Errorf ( "failed to get telegram integration: %w" , err )
}
_ , err = a . DB . Exec ( `
UPDATE telegram_integrations
SET chat_id = $ 1
WHERE id = $ 2
` , chatID , integration . ID )
if err != nil {
return fmt . Errorf ( "failed to save telegram chat_id: %w" , err )
}
return nil
}
// getTelegramBotAndChatID получает bot token и chat_id из БД и создает bot API
func ( a * App ) getTelegramBotAndChatID ( ) ( * tgbotapi . BotAPI , int64 , error ) {
integration , err := a . getTelegramIntegration ( )
if err != nil {
return nil , 0 , err
}
if integration . BotToken == nil || * integration . BotToken == "" {
return nil , 0 , nil // Bot token не настроен
}
bot , err := tgbotapi . NewBotAPI ( * integration . BotToken )
if err != nil {
return nil , 0 , fmt . Errorf ( "failed to initialize Telegram bot: %w" , err )
}
var chatID int64 = 0
if integration . ChatID != nil && * integration . ChatID != "" {
chatID , err = strconv . ParseInt ( * integration . ChatID , 10 , 64 )
if err != nil {
log . Printf ( "Warning: Invalid chat_id format in database: %v" , err )
chatID = 0
}
}
return bot , chatID , nil
}
2025-12-29 20:01:55 +03:00
func ( a * App ) sendTelegramMessage ( text string ) {
log . Printf ( "sendTelegramMessage called with text length: %d" , len ( text ) )
2025-12-31 19:11:28 +03:00
// Получаем bot и chat_id из БД
bot , chatID , err := a . getTelegramBotAndChatID ( )
if err != nil {
log . Printf ( "WARNING: Failed to get Telegram bot from database: %v, skipping message send" , err )
return
}
if bot == nil || chatID == 0 {
2025-12-29 20:01:55 +03:00
// Telegram не настроен, пропускаем отправку
2025-12-31 19:11:28 +03:00
log . Printf ( "WARNING: Telegram bot not configured (bot=%v, chatID=%d), skipping message send" , bot != nil , chatID )
2025-12-29 20:01:55 +03:00
return
}
// Конвертируем **текст** в *текст* для Markdown (Legacy)
// Markdown (Legacy) использует одинарную звездочку для жирного текста
// Используем регулярное выражение для замены только парных **
telegramText := regexp . MustCompile ( ` \*\*([^*]+)\*\* ` ) . ReplaceAllString ( text , "*$1*" )
log . Printf ( "Sending Telegram message (converted text length: %d): %s" , len ( telegramText ) , telegramText )
2025-12-31 19:11:28 +03:00
msg := tgbotapi . NewMessage ( chatID , telegramText )
2025-12-29 20:01:55 +03:00
msg . ParseMode = "Markdown" // Markdown (Legacy) format
2025-12-31 19:11:28 +03:00
_ , err = bot . Send ( msg )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "ERROR sending Telegram message: %v" , err )
} else {
2025-12-31 19:11:28 +03:00
log . Printf ( "Telegram message sent successfully to chat ID %d" , chatID )
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
2026-01-01 18:38:28 +03:00
// userID может быть nil, если пользователь не определен
func ( a * App ) processTelegramMessage ( fullText string , entities [ ] TelegramEntity , userID * int ) ( * ProcessedEntry , error ) {
2025-12-29 20:01:55 +03:00
fullText = strings . TrimSpace ( fullText )
// Регулярное выражение: 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 {
2026-01-01 18:38:28 +03:00
err := a . insertMessageData ( processedText , createdDate , scoreNodes , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "Error inserting message data: %v" , err )
return nil , fmt . Errorf ( "error inserting data: %w" , err )
}
} else {
// Если nodes нет, используем исходный текст для processedText
processedText = fullText
log . Printf ( "No nodes found in Telegram message, message will not be saved to database" )
}
// Формируем ответ
response := & ProcessedEntry {
Text : processedText ,
CreatedDate : createdDate ,
Nodes : scoreNodes ,
Raw : fullText ,
Markdown : fullText , // Для Telegram markdown не нужен
}
// Н Е отправляем сообщение обратно в Telegram (в отличие от processMessage)
return response , nil
}
// processMessage обрабатывает текст сообщения: парсит ноды, сохраняет в БД и отправляет в Telegram
2026-01-01 18:21:18 +03:00
func ( a * App ) processMessage ( rawText string , userID * int ) ( * ProcessedEntry , error ) {
return a . processMessageInternal ( rawText , true , userID )
2025-12-29 20:01:55 +03:00
}
// processMessageWithoutTelegram обрабатывает текст сообщения: парсит ноды, сохраняет в БД, но Н Е отправляет в Telegram
2026-01-01 18:21:18 +03:00
func ( a * App ) processMessageWithoutTelegram ( rawText string , userID * int ) ( * ProcessedEntry , error ) {
return a . processMessageInternal ( rawText , false , userID )
2025-12-29 20:01:55 +03:00
}
// processMessageInternal - внутренняя функция обработки сообщения
// sendToTelegram определяет, нужно ли отправлять сообщение в Telegram
2026-01-01 18:21:18 +03:00
func ( a * App ) processMessageInternal ( rawText string , sendToTelegram bool , userID * int ) ( * ProcessedEntry , error ) {
2025-12-29 20:01:55 +03:00
rawText = strings . TrimSpace ( rawText )
// Регулярное выражение для поиска **[Project][+| -][Score]**
regex := regexp . MustCompile ( ` \*\*(.+?)([+-])([\d.]+)\*\* ` )
nodes := make ( [ ] ProcessedNode , 0 )
nodeCounter := 0
// Ищем все node и заменяем их в тексте на плейсхолдеры ${0}, ${1} и т.д.
processedText := regex . ReplaceAllStringFunc ( rawText , func ( fullMatch string ) string {
matches := regex . FindStringSubmatch ( fullMatch )
if len ( matches ) != 4 {
return fullMatch
}
projectName := strings . TrimSpace ( matches [ 1 ] )
sign := matches [ 2 ]
scoreString := matches [ 3 ]
score , err := strconv . ParseFloat ( scoreString , 64 )
if err != nil {
log . Printf ( "Error parsing score: %v" , err )
return fullMatch
}
if sign == "-" {
score = - score
}
// Добавляем данные в массив nodes
nodes = append ( nodes , ProcessedNode {
Project : projectName ,
Score : score ,
} )
placeholder := fmt . Sprintf ( "${%d}" , nodeCounter )
nodeCounter ++
return placeholder
} )
// Удаляем пустые строки и лишние пробелы
lines := strings . Split ( processedText , "\n" )
cleanedLines := make ( [ ] string , 0 )
for _ , line := range lines {
trimmed := strings . TrimSpace ( line )
if trimmed != "" {
cleanedLines = append ( cleanedLines , trimmed )
}
}
processedText = strings . Join ( cleanedLines , "\n" )
// Формируем Markdown (Legacy) контент: заменяем ** на *
markdownText := strings . ReplaceAll ( rawText , "**" , "*" )
// Используем текущее время
createdDate := time . Now ( ) . UTC ( ) . Format ( time . RFC3339 )
// Вставляем данные в БД только если есть nodes
if len ( nodes ) > 0 {
2026-01-01 18:21:18 +03:00
err := a . insertMessageData ( processedText , createdDate , nodes , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "Error inserting message data: %v" , err )
return nil , fmt . Errorf ( "error inserting data: %w" , err )
}
} else {
// Если nodes нет, используем исходный текст для processedText
processedText = rawText
if sendToTelegram {
log . Printf ( "No nodes found in text, message will be sent to Telegram but not saved to database" )
} else {
log . Printf ( "No nodes found in text, message will be ignored (not saved to database and not sent to Telegram)" )
}
}
// Формируем ответ
response := & ProcessedEntry {
Text : processedText ,
CreatedDate : createdDate ,
Nodes : nodes ,
Raw : rawText ,
Markdown : markdownText ,
}
// Отправляем дублирующее сообщение в Telegram только если указано
if sendToTelegram {
a . sendTelegramMessage ( rawText )
}
return response , nil
}
func ( a * App ) messagePostHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
// Get user ID from context (may be nil for webhook)
var userIDPtr * int
if userID , ok := getUserIDFromContext ( r ) ; ok {
userIDPtr = & userID
}
2025-12-29 20:01:55 +03:00
// Парсим входящий запрос - может быть как {body: {text: ...}}, так и {text: ...}
var rawReq map [ string ] interface { }
if err := json . NewDecoder ( r . Body ) . Decode ( & rawReq ) ; err != nil {
log . Printf ( "Error decoding message post request: %v" , err )
sendErrorWithCORS ( w , "Invalid request body" , http . StatusBadRequest )
return
}
// Извлекаем text из разных возможных структур
var rawText string
if body , ok := rawReq [ "body" ] . ( map [ string ] interface { } ) ; ok {
if text , ok := body [ "text" ] . ( string ) ; ok {
rawText = text
}
}
// Если не нашли в body, пробуем напрямую
if rawText == "" {
if text , ok := rawReq [ "text" ] . ( string ) ; ok {
rawText = text
}
}
// Проверка на наличие нужного поля
if rawText == "" {
sendErrorWithCORS ( w , "Missing 'text' field in body" , http . StatusBadRequest )
return
}
// Обрабатываем сообщение
2026-01-01 18:21:18 +03:00
response , err := a . processMessage ( rawText , userIDPtr )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "Error processing message: %v" , err )
sendErrorWithCORS ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( response )
}
2026-01-01 18:21:18 +03:00
func ( a * App ) insertMessageData ( entryText string , createdDate string , nodes [ ] ProcessedNode , userID * int ) error {
2025-12-29 20:01:55 +03:00
// Начинаем транзакцию
tx , err := a . DB . Begin ( )
if err != nil {
return fmt . Errorf ( "failed to begin transaction: %w" , err )
}
defer tx . Rollback ( )
// 1. UPSERT проектов
projectNames := make ( map [ string ] bool )
for _ , node := range nodes {
projectNames [ node . Project ] = true
}
// Вставляем проекты
for projectName := range projectNames {
2026-01-01 18:21:18 +03:00
if userID != nil {
_ , err := tx . Exec ( `
INSERT INTO projects ( name , deleted , user_id )
VALUES ( $ 1 , FALSE , $ 2 )
ON CONFLICT ON CONSTRAINT unique_project_name DO UPDATE
SET name = EXCLUDED . name , deleted = FALSE
` , projectName , * userID )
if err != nil {
// Try without user constraint for backwards compatibility
_ , err = tx . Exec ( `
INSERT INTO projects ( name , deleted , user_id )
VALUES ( $ 1 , FALSE , $ 2 )
ON CONFLICT ( name ) DO UPDATE
SET name = EXCLUDED . name , deleted = FALSE , user_id = COALESCE ( projects . user_id , EXCLUDED . user_id )
` , projectName , * userID )
if err != nil {
return fmt . Errorf ( "failed to upsert project %s: %w" , projectName , err )
}
}
} else {
_ , err := tx . Exec ( `
INSERT INTO projects ( name , deleted )
VALUES ( $ 1 , FALSE )
ON CONFLICT ( name ) DO UPDATE
SET name = EXCLUDED . name , deleted = FALSE
` , projectName )
if err != nil {
return fmt . Errorf ( "failed to upsert project %s: %w" , projectName , err )
}
2025-12-29 20:01:55 +03:00
}
}
// 2. Вставляем entry
var entryID int
2026-01-01 18:21:18 +03:00
if userID != nil {
err = tx . QueryRow ( `
INSERT INTO entries ( text , created_date , user_id )
VALUES ( $ 1 , $ 2 , $ 3 )
RETURNING id
` , entryText , createdDate , * userID ) . Scan ( & entryID )
} else {
err = tx . QueryRow ( `
INSERT INTO entries ( text , created_date )
VALUES ( $ 1 , $ 2 )
RETURNING id
` , entryText , createdDate ) . Scan ( & entryID )
}
2025-12-29 20:01:55 +03:00
if err != nil {
return fmt . Errorf ( "failed to insert entry: %w" , err )
}
// 3. Вставляем nodes
for _ , node := range nodes {
_ , err := tx . Exec ( `
2025-12-29 21:31:43 +03:00
INSERT INTO nodes ( project_id , entry_id , score )
SELECT p . id , $ 1 , $ 2
FROM projects p
WHERE p . name = $ 3 AND p . deleted = FALSE
2025-12-29 20:01:55 +03:00
` , entryID , node . Score , node . Project )
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
2025-12-29 21:31:43 +03:00
WHERE p . deleted = FALSE
2025-12-29 20:01:55 +03:00
ON CONFLICT ( project_id , goal_year , goal_week ) DO UPDATE
SET
min_goal_score = EXCLUDED . min_goal_score ,
max_goal_score = EXCLUDED . max_goal_score ,
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
}
// sendWeeklyGoalsTelegramMessage получает зафиксированные цели и отправляет их в Telegram
func ( a * App ) sendWeeklyGoalsTelegramMessage ( ) error {
// Получаем цели из базы данных
selectQuery := `
SELECT
p . name AS project_name ,
wg . min_goal_score ,
wg . max_goal_score
FROM
weekly_goals wg
JOIN
projects p ON wg . project_id = p . id
WHERE
wg . goal_year = EXTRACT ( ISOYEAR FROM CURRENT_DATE ) : : INTEGER
AND wg . goal_week = EXTRACT ( WEEK FROM CURRENT_DATE ) : : INTEGER
2025-12-29 21:31:43 +03:00
AND p . deleted = FALSE
2025-12-29 20:01:55 +03:00
ORDER BY
p . name
`
rows , err := a . DB . Query ( selectQuery )
if err != nil {
return fmt . Errorf ( "error querying weekly goals: %w" , err )
}
defer rows . Close ( )
goals := make ( [ ] WeeklyGoalSetup , 0 )
for rows . Next ( ) {
var goal WeeklyGoalSetup
var maxGoalScore sql . NullFloat64
err := rows . Scan (
& goal . ProjectName ,
& goal . MinGoalScore ,
& maxGoalScore ,
)
if err != nil {
log . Printf ( "Error scanning weekly goal row: %v" , err )
continue
}
if maxGoalScore . Valid {
goal . MaxGoalScore = maxGoalScore . Float64
} else {
// Если maxGoalScore не установлен (NULL), используем NaN для корректной проверки в форматировании
goal . MaxGoalScore = math . NaN ( )
}
goals = append ( goals , goal )
}
// Форматируем сообщение
message := a . formatWeeklyGoalsMessage ( goals )
if message == "" {
log . Println ( "No goals to send in Telegram message" )
return nil
}
// Отправляем сообщение в Telegram
a . sendTelegramMessage ( message )
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
2025-12-29 21:31:43 +03:00
AND p . deleted = FALSE
2025-12-29 20:01:55 +03:00
ORDER BY
p . name
`
rows , err := a . DB . Query ( selectQuery )
if err != nil {
log . Printf ( "Error querying weekly goals: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error querying weekly goals: %v" , err ) , http . StatusInternalServerError )
return
}
defer rows . Close ( )
goals := make ( [ ] WeeklyGoalSetup , 0 )
for rows . Next ( ) {
var goal WeeklyGoalSetup
var maxGoalScore sql . NullFloat64
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 )
}
2025-12-29 20:58:34 +03:00
// recreateMaterializedViewHandler пересоздает materialized view с исправленной логикой ISOYEAR
func ( a * App ) recreateMaterializedViewHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
log . Printf ( "Recreating materialized view weekly_report_mv with ISOYEAR fix" )
// Удаляем старый view
dropMaterializedView := ` DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv `
if _ , err := a . DB . Exec ( dropMaterializedView ) ; err != nil {
log . Printf ( "Error dropping materialized view: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error dropping materialized view: %v" , err ) , http . StatusInternalServerError )
return
}
// Создаем новый view с ISOYEAR
createMaterializedView := `
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p . id AS project_id ,
agg . report_year ,
agg . report_week ,
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
2025-12-29 21:31:43 +03:00
WHERE
p . deleted = FALSE
2025-12-29 20:58:34 +03:00
ORDER BY
p . id , agg . report_year , agg . report_week
`
if _ , err := a . DB . Exec ( createMaterializedView ) ; err != nil {
log . Printf ( "Error creating materialized view: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error creating materialized view: %v" , err ) , http . StatusInternalServerError )
return
}
// Создаем индекс
createMVIndex := `
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
ON weekly_report_mv ( project_id , report_year , report_week )
`
if _ , err := a . DB . Exec ( createMVIndex ) ; err != nil {
log . Printf ( "Warning: Failed to create materialized view index: %v" , err )
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] string {
"message" : "Materialized view recreated successfully with ISOYEAR fix" ,
} )
}
2025-12-29 20:01:55 +03:00
func ( a * App ) getProjectsHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
query := `
SELECT
id AS project_id ,
name AS project_name ,
priority
FROM
projects
2025-12-29 21:31:43 +03:00
WHERE
2026-01-01 18:21:18 +03:00
deleted = FALSE AND user_id = $ 1
2025-12-29 20:01:55 +03:00
ORDER BY
priority ASC NULLS LAST ,
project_name
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "Error querying projects: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error querying projects: %v" , err ) , http . StatusInternalServerError )
return
}
defer rows . Close ( )
projects := make ( [ ] Project , 0 )
for rows . Next ( ) {
var project Project
var priority sql . NullInt64
err := rows . Scan (
& project . ProjectID ,
& project . ProjectName ,
& priority ,
)
if err != nil {
log . Printf ( "Error scanning project row: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error scanning data: %v" , err ) , http . StatusInternalServerError )
return
}
if priority . Valid {
priorityVal := int ( priority . Int64 )
project . Priority = & priorityVal
}
projects = append ( projects , project )
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( projects )
}
func ( a * App ) setProjectPriorityHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
_ = userID // Will be used in SQL queries
2025-12-29 20:01:55 +03:00
// Читаем тело запроса один раз
bodyBytes , err := io . ReadAll ( r . Body )
if err != nil {
log . Printf ( "Error reading request body: %v" , err )
sendErrorWithCORS ( w , "Error reading request body" , http . StatusBadRequest )
return
}
defer r . Body . Close ( )
// Парсим входящий запрос - может быть как {body: [...]}, так и просто массив
var projectsToUpdate [ ] ProjectPriorityUpdate
// Сначала пробуем декодировать как прямой массив
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
2026-01-01 18:21:18 +03:00
WHERE id = $ 1 AND user_id = $ 2
` , project . ID , userID )
2025-12-29 20:01:55 +03:00
} else {
_ , err = tx . Exec ( `
UPDATE projects
SET priority = $ 1
2026-01-01 18:21:18 +03:00
WHERE id = $ 2 AND user_id = $ 3
` , * project . Priority , project . ID , userID )
2025-12-29 20:01:55 +03:00
}
if err != nil {
log . Printf ( "Error updating project %d priority: %v" , project . ID , err )
tx . Rollback ( )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error updating project %d: %v" , project . ID , err ) , http . StatusInternalServerError )
return
}
}
// Коммитим транзакцию
if err := tx . Commit ( ) ; err != nil {
log . Printf ( "Error committing transaction: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error committing transaction: %v" , err ) , http . StatusInternalServerError )
return
}
// Возвращаем успешный ответ
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"message" : fmt . Sprintf ( "Updated priorities for %d projects" , len ( projectsToUpdate ) ) ,
"updated" : len ( projectsToUpdate ) ,
} )
}
2025-12-29 21:31:43 +03:00
type ProjectMoveRequest struct {
ID int ` json:"id" `
NewName string ` json:"new_name" `
}
type ProjectDeleteRequest struct {
ID int ` json:"id" `
}
func ( a * App ) moveProjectHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
_ = userID // Will be used in SQL queries
2025-12-29 21:31:43 +03:00
var req ProjectMoveRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
log . Printf ( "Error decoding move project request: %v" , err )
sendErrorWithCORS ( w , "Invalid request body" , http . StatusBadRequest )
return
}
if req . NewName == "" {
sendErrorWithCORS ( w , "new_name is required" , http . StatusBadRequest )
return
}
// Начинаем транзакцию
tx , err := a . DB . Begin ( )
if err != nil {
log . Printf ( "Error beginning transaction: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error beginning transaction: %v" , err ) , http . StatusInternalServerError )
return
}
defer tx . Rollback ( )
// Ищем проект с таким именем
var targetProjectID int
err = tx . QueryRow ( `
SELECT id FROM projects WHERE name = $ 1 AND deleted = FALSE
` , req . NewName ) . Scan ( & targetProjectID )
if err == sql . ErrNoRows {
2025-12-29 21:38:43 +03:00
// Проект не найден - просто переименовываем текущий проект
_ , err = tx . Exec ( `
UPDATE projects
SET name = $ 1
WHERE id = $ 2
` , req . NewName , req . ID )
2025-12-29 21:31:43 +03:00
if err != nil {
2025-12-29 21:38:43 +03:00
log . Printf ( "Error renaming project: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error renaming project: %v" , err ) , http . StatusInternalServerError )
return
}
// Коммитим транзакцию
if err := tx . Commit ( ) ; err != nil {
log . Printf ( "Error committing transaction: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error committing transaction: %v" , err ) , http . StatusInternalServerError )
2025-12-29 21:31:43 +03:00
return
}
2025-12-29 21:38:43 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"message" : "Project renamed successfully" ,
"project_id" : req . ID ,
} )
return
2025-12-29 21:31:43 +03:00
} else if err != nil {
log . Printf ( "Error querying target project: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error querying target project: %v" , err ) , http . StatusInternalServerError )
return
}
2025-12-29 21:38:43 +03:00
// Проект найден - переносим данные в существующий проект
finalProjectID := targetProjectID
2025-12-29 21:31:43 +03:00
// Обновляем все nodes с project_id на целевой
_ , err = tx . Exec ( `
UPDATE nodes
SET project_id = $ 1
WHERE project_id = $ 2
` , finalProjectID , req . ID )
if err != nil {
log . Printf ( "Error updating nodes: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error updating nodes: %v" , err ) , http . StatusInternalServerError )
return
}
// Обновляем weekly_goals
// Сначала удаляем записи старого проекта, которые конфликтуют с записями целевого проекта
// (если у целевого проекта уже есть запись для той же недели)
_ , err = tx . Exec ( `
DELETE FROM weekly_goals
WHERE project_id = $ 1
AND EXISTS (
SELECT 1
FROM weekly_goals wg2
WHERE wg2 . project_id = $ 2
AND wg2 . goal_year = weekly_goals . goal_year
AND wg2 . goal_week = weekly_goals . goal_week
)
` , req . ID , finalProjectID )
if err != nil {
log . Printf ( "Error deleting conflicting weekly_goals: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error deleting conflicting weekly_goals: %v" , err ) , http . StatusInternalServerError )
return
}
// Теперь обновляем оставшиеся записи (те, которые не конфликтуют)
_ , 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 )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 21:31:43 +03:00
var req ProjectDeleteRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
log . Printf ( "Error decoding delete project request: %v" , err )
sendErrorWithCORS ( w , "Invalid request body" , http . StatusBadRequest )
return
}
2026-01-01 18:21:18 +03:00
// Verify ownership
var ownerID int
err := a . DB . QueryRow ( "SELECT user_id FROM projects WHERE id = $1" , req . ID ) . Scan ( & ownerID )
if err != nil || ownerID != userID {
sendErrorWithCORS ( w , "Project not found" , http . StatusNotFound )
return
}
2025-12-29 21:31:43 +03:00
// Начинаем транзакцию
tx , err := a . DB . Begin ( )
if err != nil {
log . Printf ( "Error beginning transaction: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error beginning transaction: %v" , err ) , http . StatusInternalServerError )
return
}
defer tx . Rollback ( )
// Удаляем все записи weekly_goals для этого проекта
_ , err = tx . Exec ( `
DELETE FROM weekly_goals
WHERE project_id = $ 1
` , req . ID )
if err != nil {
log . Printf ( "Error deleting weekly_goals: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error deleting weekly_goals: %v" , err ) , http . StatusInternalServerError )
return
}
// Помечаем проект как удаленный
_ , err = tx . Exec ( `
UPDATE projects
SET deleted = TRUE
WHERE id = $ 1
` , req . ID )
if err != nil {
log . Printf ( "Error marking project as deleted: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error marking project as deleted: %v" , err ) , http . StatusInternalServerError )
return
}
// Коммитим транзакцию
if err := tx . Commit ( ) ; err != nil {
log . Printf ( "Error committing transaction: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error committing transaction: %v" , err ) , http . StatusInternalServerError )
return
}
// Обновляем 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" ,
} )
}
2025-12-29 20:01:55 +03:00
func ( a * App ) todoistWebhookHandler ( w http . ResponseWriter , r * http . Request ) {
// Логирование входящего запроса
log . Printf ( "=== Todoist Webhook Request ===" )
log . Printf ( "Method: %s" , r . Method )
log . Printf ( "URL: %s" , r . URL . String ( ) )
2026-01-01 18:50:55 +03:00
log . Printf ( "Path: %s" , r . URL . Path )
2025-12-29 20:01:55 +03:00
log . Printf ( "RemoteAddr: %s" , r . RemoteAddr )
if r . Method == "OPTIONS" {
log . Printf ( "OPTIONS request, returning OK" )
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:38:28 +03:00
// Извлекаем токен из URL
vars := mux . Vars ( r )
token := vars [ "token" ]
2026-01-01 18:50:55 +03:00
log . Printf ( "Extracted token from URL: '%s'" , token )
2026-01-01 18:38:28 +03:00
if token == "" {
log . Printf ( "Todoist webhook: missing token in URL" )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : "Missing webhook token" ,
"message" : "Token required in URL" ,
} )
2026-01-01 18:38:28 +03:00
return
}
// Находим пользователя по токену из telegram_integrations (используем тот же механизм)
var userID int
err := a . DB . QueryRow ( `
SELECT user_id FROM telegram_integrations
WHERE webhook_token = $ 1 AND user_id IS NOT NULL
LIMIT 1
` , token ) . Scan ( & userID )
if err == sql . ErrNoRows {
log . Printf ( "Todoist webhook: invalid token: %s" , token )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : "Invalid webhook token" ,
"message" : "Token not found" ,
} )
2026-01-01 18:38:28 +03:00
return
} else if err != nil {
log . Printf ( "Error finding user by webhook token: %v" , err )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : "Internal server error" ,
"message" : "Database error" ,
} )
2026-01-01 18:38:28 +03:00
return
}
log . Printf ( "Todoist webhook: token=%s, user_id=%d" , token , userID )
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 )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : "Error reading request body" ,
"message" : "Failed to read request" ,
} )
2025-12-29 20:01:55 +03:00
return
}
// Логируем сырое тело запроса
log . Printf ( "Request body (raw): %s" , string ( bodyBytes ) )
log . Printf ( "Request body length: %d bytes" , len ( bodyBytes ) )
// Создаем новый reader из прочитанных байтов для парсинга
r . Body = io . NopCloser ( bytes . NewBuffer ( bodyBytes ) )
// Опциональная проверка секрета webhook (если задан в переменных окружения)
todoistWebhookSecret := getEnv ( "TODOIST_WEBHOOK_SECRET" , "" )
log . Printf ( "Webhook secret check: configured=%v" , todoistWebhookSecret != "" )
if todoistWebhookSecret != "" {
providedSecret := r . Header . Get ( "X-Todoist-Webhook-Secret" )
log . Printf ( "Provided secret in header: %v (length: %d)" , providedSecret != "" , len ( providedSecret ) )
if providedSecret != todoistWebhookSecret {
log . Printf ( "Invalid Todoist webhook secret provided (expected length: %d, provided length: %d)" , len ( todoistWebhookSecret ) , len ( providedSecret ) )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : "Unauthorized" ,
"message" : "Invalid webhook secret" ,
} )
2025-12-29 20:01:55 +03:00
return
}
log . Printf ( "Webhook secret validated successfully" )
}
// Парсим webhook от Todoist
var webhook TodoistWebhook
if err := json . NewDecoder ( r . Body ) . Decode ( & webhook ) ; err != nil {
log . Printf ( "Error decoding Todoist webhook: %v" , err )
log . Printf ( "Failed to parse body as JSON: %s" , string ( bodyBytes ) )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : "Invalid request body" ,
"message" : "Failed to parse JSON" ,
} )
2025-12-29 20:01:55 +03:00
return
}
// Логируем структуру webhook после парсинга
log . Printf ( "Parsed webhook structure:" )
log . Printf ( " EventName: %s" , webhook . EventName )
log . Printf ( " EventData keys: %v" , getMapKeys ( webhook . EventData ) )
if eventDataJSON , err := json . MarshalIndent ( webhook . EventData , " " , " " ) ; err == nil {
log . Printf ( " EventData content:\n%s" , string ( eventDataJSON ) )
} else {
log . Printf ( " EventData (marshal error): %v" , err )
}
// Проверяем, что это событие закрытия задачи
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" )
json . NewEncoder ( w ) . Encode ( map [ string ] string {
"message" : "Event ignored" ,
"event" : webhook . EventName ,
} )
return
}
// Извлекаем 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 ) )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : "Missing 'content' or 'description' in event_data" ,
"message" : "No content to process" ,
} )
2025-12-29 20:01:55 +03:00
return
}
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)
2026-01-01 18:38:28 +03:00
userIDPtr := & userID
log . Printf ( "Calling processMessageWithoutTelegram with combined text, user_id=%d..." , userID )
response , err := a . processMessageWithoutTelegram ( combinedText , userIDPtr )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "ERROR processing Todoist message: %v" , err )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : err . Error ( ) ,
"message" : "Error processing message" ,
} )
2025-12-29 20:01:55 +03:00
return
}
// Проверяем наличие nodes - если их нет, игнорируем сообщение
if len ( response . Nodes ) == 0 {
log . Printf ( "Todoist webhook: no nodes found in message, ignoring (not saving to database and not sending to Telegram)" )
log . Printf ( "=== Todoist Webhook Request Ignored (No Nodes) ===" )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"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 )
a . sendTelegramMessage ( combinedText )
log . Printf ( "sendTelegramMessage call completed" )
} else {
log . Printf ( "No nodes found, skipping Telegram message" )
}
log . Printf ( "=== Todoist Webhook Request Completed Successfully ===" )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-01 18:50:55 +03:00
w . WriteHeader ( http . StatusOK )
2025-12-29 20:01:55 +03:00
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-01 18:50:55 +03:00
"ok" : true ,
2025-12-29 20:01:55 +03:00
"message" : "Task processed successfully" ,
"result" : response ,
} )
}
func ( a * App ) telegramWebhookHandler ( w http . ResponseWriter , r * http . Request ) {
2026-01-01 18:50:55 +03:00
log . Printf ( "=== Telegram 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
if r . Method == "OPTIONS" {
2026-01-01 18:50:55 +03:00
log . Printf ( "OPTIONS request, returning OK" )
2025-12-29 20:01:55 +03:00
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:38:28 +03:00
// Извлекаем токен из URL
vars := mux . Vars ( r )
token := vars [ "token" ]
2026-01-01 18:50:55 +03:00
log . Printf ( "Extracted token from URL: '%s'" , token )
2026-01-01 18:38:28 +03:00
if token == "" {
log . Printf ( "Telegram webhook: missing token in URL" )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : "Missing webhook token" ,
"message" : "Token required in URL" ,
} )
2026-01-01 18:38:28 +03:00
return
}
// Находим пользователя по токену
var userID int
err := a . DB . QueryRow ( `
SELECT user_id FROM telegram_integrations
WHERE webhook_token = $ 1 AND user_id IS NOT NULL
LIMIT 1
` , token ) . Scan ( & userID )
if err == sql . ErrNoRows {
log . Printf ( "Telegram webhook: invalid token: %s" , token )
2026-01-01 18:50:55 +03:00
// Возвращаем 200 OK, но логируем ошибку (не хотим, чтобы Telegram повторял запрос)
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : "Invalid webhook token" ,
"message" : "Token not found" ,
} )
2026-01-01 18:38:28 +03:00
return
} else if err != nil {
log . Printf ( "Error finding user by webhook token: %v" , err )
2026-01-01 18:50:55 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
w . WriteHeader ( http . StatusOK )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : false ,
"error" : "Internal server error" ,
"message" : "Database error" ,
} )
2026-01-01 18:38:28 +03:00
return
}
log . Printf ( "Telegram webhook: token=%s, user_id=%d" , token , userID )
2025-12-29 20:01:55 +03:00
// Парсим webhook от Telegram
var update TelegramUpdate
if err := json . NewDecoder ( r . Body ) . Decode ( & update ) ; err != nil {
log . Printf ( "Error decoding Telegram webhook: %v" , err )
2026-01-01 18:50:55 +03:00
// Возвращаем 200 OK, чтобы Telegram не повторял запрос
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 decode webhook" ,
} )
2025-12-29 20:01:55 +03:00
return
}
2025-12-31 19:39:01 +03:00
// Определяем, какое сообщение использовать (message или edited_message)
var message * TelegramMessage
if update . Message != nil {
message = update . Message
log . Printf ( "Telegram webhook received: update_id=%d, message type=message" , update . UpdateID )
} else if update . EditedMessage != nil {
message = update . EditedMessage
log . Printf ( "Telegram webhook received: update_id=%d, message type=edited_message" , update . UpdateID )
} else {
log . Printf ( "Telegram webhook received: update_id=%d, but no message or edited_message found" , update . UpdateID )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-01 18:50:55 +03:00
w . WriteHeader ( http . StatusOK ) // Возвращаем 200 OK для Telegram
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : true ,
2025-12-31 19:39:01 +03:00
"message" : "No message found in update" ,
} )
return
}
2026-01-01 18:38:28 +03:00
log . Printf ( "Telegram webhook: message present, chat_id=%d, user_id=%d" , message . Chat . ID , userID )
2025-12-31 19:39:01 +03:00
2026-01-01 18:38:28 +03:00
// Сохраняем chat_id при первом сообщении (если еще не сохранен)
2025-12-31 19:39:01 +03:00
if message . Chat . ID != 0 {
chatIDStr := strconv . FormatInt ( message . Chat . ID , 10 )
2026-01-01 18:38:28 +03:00
var existingChatID sql . NullString
err := a . DB . QueryRow ( `
SELECT chat_id FROM telegram_integrations
WHERE user_id = $ 1
LIMIT 1
` , userID ) . Scan ( & existingChatID )
if err == nil && ( ! existingChatID . Valid || existingChatID . String == "" ) {
2025-12-31 19:11:28 +03:00
// Сохраняем chat_id, если е г о еще нет
2026-01-01 18:38:28 +03:00
_ , err = a . DB . Exec ( `
UPDATE telegram_integrations
SET chat_id = $ 1
WHERE user_id = $ 2
` , chatIDStr , userID )
if err != nil {
log . Printf ( "Warning: Failed to save chat_id: %v" , err )
2025-12-31 19:39:01 +03:00
} else {
2026-01-01 18:38:28 +03:00
log . Printf ( "Successfully saved chat_id from first message: %s" , chatIDStr )
2025-12-31 19:11:28 +03:00
}
}
}
2026-01-01 18:38:28 +03:00
userIDPtr := & userID
2025-12-31 19:11:28 +03:00
2025-12-31 19:39:01 +03:00
// Проверяем, что есть текст в сообщении
if message . Text == "" {
2025-12-29 20:01:55 +03:00
log . Printf ( "Telegram webhook: no text in message" )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
2026-01-01 18:50:55 +03:00
w . WriteHeader ( http . StatusOK ) // Возвращаем 200 OK для Telegram
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
"ok" : true ,
2025-12-29 20:01:55 +03:00
"message" : "No text in message, ignored" ,
} )
return
}
2025-12-31 19:39:01 +03:00
fullText := message . Text
entities := message . Entities
2025-12-29 20:01:55 +03:00
if entities == nil {
entities = [ ] TelegramEntity { }
}
2026-01-01 18:38:28 +03:00
log . Printf ( "Processing Telegram message: text='%s', entities count=%d, user_id=%d" , fullText , len ( entities ) , userID )
2025-12-29 20:01:55 +03:00
// Обрабатываем сообщение через новую логику (с entities, без отправки обратно в Telegram)
2026-01-01 18:38:28 +03:00
response , err := a . processTelegramMessage ( fullText , entities , userIDPtr )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "Error processing Telegram message: %v" , err )
2026-01-01 18:50:55 +03:00
// Возвращаем 200 OK, чтобы Telegram не повторял запрос
w . Header ( ) . Set ( "Content-Type" , "application/json" )
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
}
log . Printf ( "Successfully processed Telegram message, found %d nodes" , len ( response . Nodes ) )
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] interface { } {
2026-01-01 18:50:55 +03:00
"ok" : true ,
2025-12-29 20:01:55 +03:00
"message" : "Message processed successfully" ,
"result" : response ,
} )
}
func ( a * App ) getFullStatisticsHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-29 20:01:55 +03:00
query := `
SELECT
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
2025-12-29 21:31:43 +03:00
ON p . id = COALESCE ( wr . project_id , wg . project_id )
WHERE
2026-01-01 18:21:18 +03:00
p . deleted = FALSE AND p . user_id = $ 1
AND COALESCE ( wr . report_year , wg . goal_year ) IS NOT NULL
2025-12-29 20:01:55 +03:00
ORDER BY
report_year DESC ,
report_week DESC ,
project_name
`
2026-01-01 18:21:18 +03:00
rows , err := a . DB . Query ( query , userID )
2025-12-29 20:01:55 +03:00
if err != nil {
log . Printf ( "Error querying full statistics: %v" , err )
sendErrorWithCORS ( w , fmt . Sprintf ( "Error querying full statistics: %v" , err ) , http . StatusInternalServerError )
return
}
defer rows . Close ( )
statistics := make ( [ ] FullStatisticsItem , 0 )
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 )
}
2025-12-31 19:11:28 +03:00
// getTelegramIntegrationHandler возвращает текущую telegram интеграцию
func ( a * App ) getTelegramIntegrationHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
integration , err := a . getTelegramIntegrationForUser ( userID )
2025-12-31 19:11:28 +03:00
if err != nil {
sendErrorWithCORS ( w , fmt . Sprintf ( "Failed to get telegram integration: %v" , err ) , http . StatusInternalServerError )
return
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( integration )
}
// TelegramIntegrationUpdateRequest представляет запрос на обновление telegram интеграции
type TelegramIntegrationUpdateRequest struct {
BotToken string ` json:"bot_token" `
}
// updateTelegramIntegrationHandler обновляет bot token для telegram интеграции
func ( a * App ) updateTelegramIntegrationHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:21:18 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
2025-12-31 19:11:28 +03:00
var req TelegramIntegrationUpdateRequest
if err := json . NewDecoder ( r . Body ) . Decode ( & req ) ; err != nil {
sendErrorWithCORS ( w , "Invalid request body" , http . StatusBadRequest )
return
}
if req . BotToken == "" {
sendErrorWithCORS ( w , "bot_token is required" , http . StatusBadRequest )
return
}
2026-01-01 18:21:18 +03:00
if err := a . saveTelegramBotTokenForUser ( req . BotToken , userID ) ; err != nil {
2025-12-31 19:11:28 +03:00
sendErrorWithCORS ( w , fmt . Sprintf ( "Failed to save bot token: %v" , err ) , http . StatusInternalServerError )
return
}
2026-01-01 18:38:28 +03:00
// Получаем обновленную интеграцию с webhook токеном
integration , err := a . getTelegramIntegrationForUser ( userID )
if err != nil {
sendErrorWithCORS ( w , fmt . Sprintf ( "Failed to get updated integration: %v" , err ) , http . StatusInternalServerError )
return
}
2025-12-31 19:11:28 +03:00
// Настраиваем webhook автоматически при сохранении токена
webhookBaseURL := getEnv ( "WEBHOOK_BASE_URL" , "" )
2025-12-31 19:39:01 +03:00
log . Printf ( "Attempting to setup Telegram webhook. WEBHOOK_BASE_URL='%s'" , webhookBaseURL )
2026-01-01 18:38:28 +03:00
if webhookBaseURL != "" && integration . WebhookToken != nil && * integration . WebhookToken != "" {
webhookURL := strings . TrimRight ( webhookBaseURL , "/" ) + "/webhook/telegram/" + * integration . WebhookToken
2025-12-31 19:39:01 +03:00
log . Printf ( "Setting up Telegram webhook: URL=%s" , webhookURL )
2025-12-31 19:11:28 +03:00
if err := setupTelegramWebhook ( req . BotToken , webhookURL ) ; err != nil {
2025-12-31 19:39:01 +03:00
log . Printf ( "ERROR: Failed to setup Telegram webhook: %v" , err )
2025-12-31 19:11:28 +03:00
// Н е возвращаем ошибку, так как токен уже сохранен
} else {
2025-12-31 19:39:01 +03:00
log . Printf ( "SUCCESS: Telegram webhook configured successfully: %s" , webhookURL )
2025-12-31 19:11:28 +03:00
}
} else {
2026-01-01 18:38:28 +03:00
log . Printf ( "WARNING: WEBHOOK_BASE_URL not set or webhook_token missing. Webhook will not be configured automatically." )
2025-12-31 19:11:28 +03:00
}
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( integration )
}
// getTodoistWebhookURLHandler возвращает URL для Todoist webhook
func ( a * App ) getTodoistWebhookURLHandler ( w http . ResponseWriter , r * http . Request ) {
if r . Method == "OPTIONS" {
setCORSHeaders ( w )
w . WriteHeader ( http . StatusOK )
return
}
setCORSHeaders ( w )
2026-01-01 18:38:28 +03:00
userID , ok := getUserIDFromContext ( r )
if ! ok {
sendErrorWithCORS ( w , "Unauthorized" , http . StatusUnauthorized )
return
}
// Получаем webhook токен для пользователя
integration , err := a . getTelegramIntegrationForUser ( userID )
if err != nil {
sendErrorWithCORS ( w , fmt . Sprintf ( "Failed to get telegram integration: %v" , err ) , http . StatusInternalServerError )
return
}
if integration . WebhookToken == nil || * integration . WebhookToken == "" {
sendErrorWithCORS ( w , "Webhook token not available" , http . StatusInternalServerError )
return
}
2025-12-31 19:11:28 +03:00
// Получаем base URL из env
baseURL := getEnv ( "WEBHOOK_BASE_URL" , "" )
if baseURL == "" {
sendErrorWithCORS ( w , "WEBHOOK_BASE_URL not configured" , http . StatusInternalServerError )
return
}
2026-01-01 18:38:28 +03:00
webhookURL := strings . TrimRight ( baseURL , "/" ) + "/webhook/todoist/" + * integration . WebhookToken
2025-12-31 19:11:28 +03:00
w . Header ( ) . Set ( "Content-Type" , "application/json" )
json . NewEncoder ( w ) . Encode ( map [ string ] string {
"webhook_url" : webhookURL ,
} )
}