2025-12-29 20:01:55 +03:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"database/sql"
|
|
|
|
|
|
"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"
|
|
|
|
|
|
"github.com/gorilla/mux"
|
|
|
|
|
|
"github.com/joho/godotenv"
|
|
|
|
|
|
_ "github.com/lib/pq"
|
|
|
|
|
|
"github.com/robfig/cron/v3"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2025-12-31 19:39:01 +03:00
|
|
|
|
UpdateID int `json:"update_id"`
|
|
|
|
|
|
Message *TelegramMessage `json:"message,omitempty"`
|
|
|
|
|
|
EditedMessage *TelegramMessage `json:"edited_message,omitempty"`
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func setCORSHeaders(w http.ResponseWriter) {
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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" {
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
LEFT JOIN progress p ON w.id = p.word_id
|
|
|
|
|
|
WHERE ($1::INTEGER IS NULL OR w.dictionary_id = $1)
|
|
|
|
|
|
ORDER BY w.id
|
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
|
|
var rows *sql.Rows
|
|
|
|
|
|
var err error
|
|
|
|
|
|
if dictionaryID != nil {
|
|
|
|
|
|
rows, err = a.DB.Query(query, *dictionaryID)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
rows, err = a.DB.Query(query, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
words := make([]Word, 0)
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var word Word
|
|
|
|
|
|
var lastSuccess, lastFailure sql.NullString
|
|
|
|
|
|
|
|
|
|
|
|
err := rows.Scan(
|
|
|
|
|
|
&word.ID,
|
|
|
|
|
|
&word.Name,
|
|
|
|
|
|
&word.Translation,
|
|
|
|
|
|
&word.Description,
|
|
|
|
|
|
&word.Success,
|
|
|
|
|
|
&word.Failure,
|
|
|
|
|
|
&lastSuccess,
|
|
|
|
|
|
&lastFailure,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if lastSuccess.Valid {
|
|
|
|
|
|
word.LastSuccess = &lastSuccess.String
|
|
|
|
|
|
}
|
|
|
|
|
|
if lastFailure.Valid {
|
|
|
|
|
|
word.LastFailure = &lastFailure.String
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
words = append(words, word)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
json.NewEncoder(w).Encode(words)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) addWordsHandler(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if r.Method == "OPTIONS" {
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req WordsRequest
|
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tx, err := a.DB.Begin()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
|
|
stmt, err := tx.Prepare(`
|
|
|
|
|
|
INSERT INTO words (name, translation, description, dictionary_id)
|
|
|
|
|
|
VALUES ($1, $2, $3, COALESCE($4, 0))
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
`)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer stmt.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var addedCount int
|
|
|
|
|
|
for _, wordReq := range req.Words {
|
|
|
|
|
|
var id int
|
|
|
|
|
|
dictionaryID := 0
|
|
|
|
|
|
if wordReq.DictionaryID != nil {
|
|
|
|
|
|
dictionaryID = *wordReq.DictionaryID
|
|
|
|
|
|
}
|
|
|
|
|
|
err := stmt.QueryRow(wordReq.Name, wordReq.Translation, wordReq.Description, dictionaryID).Scan(&id)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
addedCount++
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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": 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get config_id from query parameter (required)
|
|
|
|
|
|
configIDStr := r.URL.Query().Get("config_id")
|
|
|
|
|
|
if configIDStr == "" {
|
|
|
|
|
|
sendErrorWithCORS(w, "config_id parameter is required", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
configID, err := strconv.Atoi(configIDStr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
sendErrorWithCORS(w, "invalid config_id parameter", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get words_count from config
|
|
|
|
|
|
var wordsCount int
|
|
|
|
|
|
err = a.DB.QueryRow("SELECT words_count FROM configs WHERE id = $1", configID).Scan(&wordsCount)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
|
|
sendErrorWithCORS(w, "config not found", http.StatusNotFound)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get dictionary IDs for this config
|
|
|
|
|
|
var dictionaryIDs []int
|
|
|
|
|
|
dictQuery := `
|
|
|
|
|
|
SELECT dictionary_id
|
|
|
|
|
|
FROM config_dictionaries
|
|
|
|
|
|
WHERE config_id = $1
|
|
|
|
|
|
`
|
|
|
|
|
|
dictRows, err := a.DB.Query(dictQuery, configID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer dictRows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
for dictRows.Next() {
|
|
|
|
|
|
var dictID int
|
|
|
|
|
|
if err := dictRows.Scan(&dictID); err != nil {
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
dictionaryIDs = append(dictionaryIDs, dictID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If no dictionaries are selected for config, use all dictionaries (no filter)
|
|
|
|
|
|
var dictFilter string
|
|
|
|
|
|
var dictArgs []interface{}
|
|
|
|
|
|
if len(dictionaryIDs) > 0 {
|
|
|
|
|
|
placeholders := make([]string, len(dictionaryIDs))
|
|
|
|
|
|
for i := range dictionaryIDs {
|
|
|
|
|
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
|
|
|
|
}
|
|
|
|
|
|
dictFilter = fmt.Sprintf("w.dictionary_id IN (%s)", strings.Join(placeholders, ","))
|
|
|
|
|
|
for _, dictID := range dictionaryIDs {
|
|
|
|
|
|
dictArgs = append(dictArgs, dictID)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
dictFilter = "1=1" // No filter
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate group sizes (use ceiling to ensure we don't lose words due to rounding)
|
|
|
|
|
|
group1Count := int(float64(wordsCount) * 0.3) // 30%
|
|
|
|
|
|
group2Count := int(float64(wordsCount) * 0.4) // 40%
|
|
|
|
|
|
// group3Count is calculated dynamically based on actual words collected from groups 1 and 2
|
|
|
|
|
|
|
|
|
|
|
|
// Base query parts
|
|
|
|
|
|
baseSelect := `
|
|
|
|
|
|
w.id,
|
|
|
|
|
|
w.name,
|
|
|
|
|
|
w.translation,
|
|
|
|
|
|
w.description,
|
|
|
|
|
|
COALESCE(p.success, 0) as success,
|
|
|
|
|
|
COALESCE(p.failure, 0) as failure,
|
|
|
|
|
|
CASE WHEN p.last_success_at IS NOT NULL THEN p.last_success_at::text ELSE NULL END as last_success_at,
|
|
|
|
|
|
CASE WHEN p.last_failure_at IS NOT NULL THEN p.last_failure_at::text ELSE NULL END as last_failure_at
|
|
|
|
|
|
`
|
|
|
|
|
|
baseFrom := `
|
|
|
|
|
|
FROM words w
|
|
|
|
|
|
LEFT JOIN progress p ON w.id = p.word_id
|
|
|
|
|
|
WHERE ` + dictFilter
|
|
|
|
|
|
|
|
|
|
|
|
// Group 1: success <= 3, sorted by success ASC, then last_success_at ASC (NULL first)
|
|
|
|
|
|
group1Query := `
|
|
|
|
|
|
SELECT ` + baseSelect + `
|
|
|
|
|
|
` + baseFrom + `
|
|
|
|
|
|
AND COALESCE(p.success, 0) <= 3
|
|
|
|
|
|
ORDER BY
|
|
|
|
|
|
COALESCE(p.success, 0) ASC,
|
|
|
|
|
|
CASE WHEN p.last_success_at IS NULL THEN 0 ELSE 1 END,
|
|
|
|
|
|
p.last_success_at ASC
|
|
|
|
|
|
LIMIT $` + fmt.Sprintf("%d", len(dictArgs)+1)
|
|
|
|
|
|
|
|
|
|
|
|
group1Args := append(dictArgs, group1Count*2) // Get more to ensure uniqueness
|
|
|
|
|
|
group1Rows, err := a.DB.Query(group1Query, group1Args...)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer group1Rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
group1Words := make([]Word, 0)
|
|
|
|
|
|
group1WordIDs := make(map[int]bool)
|
|
|
|
|
|
for group1Rows.Next() && len(group1Words) < group1Count {
|
|
|
|
|
|
var word Word
|
|
|
|
|
|
var lastSuccess, lastFailure sql.NullString
|
|
|
|
|
|
|
|
|
|
|
|
err := group1Rows.Scan(
|
|
|
|
|
|
&word.ID,
|
|
|
|
|
|
&word.Name,
|
|
|
|
|
|
&word.Translation,
|
|
|
|
|
|
&word.Description,
|
|
|
|
|
|
&word.Success,
|
|
|
|
|
|
&word.Failure,
|
|
|
|
|
|
&lastSuccess,
|
|
|
|
|
|
&lastFailure,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if lastSuccess.Valid {
|
|
|
|
|
|
word.LastSuccess = &lastSuccess.String
|
|
|
|
|
|
}
|
|
|
|
|
|
if lastFailure.Valid {
|
|
|
|
|
|
word.LastFailure = &lastFailure.String
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
group1Words = append(group1Words, word)
|
|
|
|
|
|
group1WordIDs[word.ID] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Group 2: (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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req TestProgressRequest
|
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
|
log.Printf("Error decoding request: %v", err)
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log.Printf("Received %d word updates, config_id: %v", len(req.Words), req.ConfigID)
|
|
|
|
|
|
|
|
|
|
|
|
tx, err := a.DB.Begin()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
|
|
stmt, err := tx.Prepare(`
|
|
|
|
|
|
INSERT INTO progress (word_id, success, failure, last_success_at, last_failure_at)
|
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
|
|
|
|
ON CONFLICT (word_id)
|
|
|
|
|
|
DO UPDATE SET
|
|
|
|
|
|
success = EXCLUDED.success,
|
|
|
|
|
|
failure = EXCLUDED.failure,
|
|
|
|
|
|
last_success_at = COALESCE(EXCLUDED.last_success_at, progress.last_success_at),
|
|
|
|
|
|
last_failure_at = COALESCE(EXCLUDED.last_failure_at, progress.last_failure_at)
|
|
|
|
|
|
`)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer stmt.Close()
|
|
|
|
|
|
|
|
|
|
|
|
for _, wordUpdate := range req.Words {
|
|
|
|
|
|
// Convert pointers to values for logging
|
|
|
|
|
|
lastSuccessStr := "nil"
|
|
|
|
|
|
if wordUpdate.LastSuccessAt != nil {
|
|
|
|
|
|
lastSuccessStr = *wordUpdate.LastSuccessAt
|
|
|
|
|
|
}
|
|
|
|
|
|
lastFailureStr := "nil"
|
|
|
|
|
|
if wordUpdate.LastFailureAt != nil {
|
|
|
|
|
|
lastFailureStr = *wordUpdate.LastFailureAt
|
|
|
|
|
|
}
|
|
|
|
|
|
log.Printf("Updating word %d: success=%d, failure=%d, last_success_at=%s, last_failure_at=%s",
|
|
|
|
|
|
wordUpdate.ID, wordUpdate.Success, wordUpdate.Failure, lastSuccessStr, lastFailureStr)
|
|
|
|
|
|
|
|
|
|
|
|
// Convert pointers to sql.NullString for proper NULL handling
|
|
|
|
|
|
var lastSuccess, lastFailure interface{}
|
|
|
|
|
|
if wordUpdate.LastSuccessAt != nil && *wordUpdate.LastSuccessAt != "" {
|
|
|
|
|
|
lastSuccess = *wordUpdate.LastSuccessAt
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lastSuccess = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if wordUpdate.LastFailureAt != nil && *wordUpdate.LastFailureAt != "" {
|
|
|
|
|
|
lastFailure = *wordUpdate.LastFailureAt
|
|
|
|
|
|
} else {
|
|
|
|
|
|
lastFailure = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_, err := stmt.Exec(
|
|
|
|
|
|
wordUpdate.ID,
|
|
|
|
|
|
wordUpdate.Success,
|
|
|
|
|
|
wordUpdate.Failure,
|
|
|
|
|
|
lastSuccess,
|
|
|
|
|
|
lastFailure,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Error executing update for word %d: %v", wordUpdate.ID, err)
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If config_id is provided, send webhook with try_message
|
|
|
|
|
|
if req.ConfigID != nil {
|
|
|
|
|
|
configID := *req.ConfigID
|
|
|
|
|
|
|
|
|
|
|
|
// Use mutex to prevent duplicate webhook sends
|
|
|
|
|
|
a.webhookMutex.Lock()
|
|
|
|
|
|
lastTime, exists := a.lastWebhookTime[configID]
|
|
|
|
|
|
now := time.Now()
|
|
|
|
|
|
|
|
|
|
|
|
// Only send webhook if it hasn't been sent in the last 5 seconds for this config
|
|
|
|
|
|
shouldSend := !exists || now.Sub(lastTime) > 5*time.Second
|
|
|
|
|
|
|
|
|
|
|
|
if shouldSend {
|
|
|
|
|
|
a.lastWebhookTime[configID] = now
|
|
|
|
|
|
}
|
|
|
|
|
|
a.webhookMutex.Unlock()
|
|
|
|
|
|
|
|
|
|
|
|
if !shouldSend {
|
|
|
|
|
|
log.Printf("Webhook skipped for config_id %d (sent recently)", configID)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
var tryMessage sql.NullString
|
|
|
|
|
|
err := a.DB.QueryRow("SELECT try_message FROM configs WHERE id = $1", configID).Scan(&tryMessage)
|
|
|
|
|
|
if err == nil && tryMessage.Valid && tryMessage.String != "" {
|
|
|
|
|
|
// Process message directly (backend always runs together with frontend)
|
|
|
|
|
|
_, err := a.processMessage(tryMessage.String)
|
|
|
|
|
|
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" {
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
query := `
|
|
|
|
|
|
SELECT id, name, words_count, max_cards, try_message
|
|
|
|
|
|
FROM configs
|
|
|
|
|
|
ORDER BY id
|
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
|
|
rows, err := a.DB.Query(query)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
configs := make([]Config, 0)
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var config Config
|
|
|
|
|
|
var maxCards sql.NullInt64
|
|
|
|
|
|
err := rows.Scan(
|
|
|
|
|
|
&config.ID,
|
|
|
|
|
|
&config.Name,
|
|
|
|
|
|
&config.WordsCount,
|
|
|
|
|
|
&maxCards,
|
|
|
|
|
|
&config.TryMessage,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if maxCards.Valid {
|
|
|
|
|
|
maxCardsVal := int(maxCards.Int64)
|
|
|
|
|
|
config.MaxCards = &maxCardsVal
|
|
|
|
|
|
}
|
|
|
|
|
|
configs = append(configs, config)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
json.NewEncoder(w).Encode(configs)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) getDictionariesHandler(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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
GROUP BY d.id, d.name
|
|
|
|
|
|
ORDER BY d.id
|
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
|
|
rows, err := a.DB.Query(query)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
dictionaries := make([]Dictionary, 0)
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var dict Dictionary
|
|
|
|
|
|
err := rows.Scan(
|
|
|
|
|
|
&dict.ID,
|
|
|
|
|
|
&dict.Name,
|
|
|
|
|
|
&dict.WordsCount,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
dictionaries = append(dictionaries, dict)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
json.NewEncoder(w).Encode(dictionaries)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) addDictionaryHandler(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if r.Method == "OPTIONS" {
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req DictionaryRequest
|
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if req.Name == "" {
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "Имя словаря обязательно"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var id int
|
|
|
|
|
|
err := a.DB.QueryRow(`
|
|
|
|
|
|
INSERT INTO dictionaries (name)
|
|
|
|
|
|
VALUES ($1)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
`, req.Name).Scan(&id)
|
|
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
|
|
|
"id": id,
|
|
|
|
|
|
"name": req.Name,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) updateDictionaryHandler(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if r.Method == "OPTIONS" {
|
|
|
|
|
|
setCORSHeaders(w)
|
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setCORSHeaders(w)
|
|
|
|
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
|
|
dictionaryID := vars["id"]
|
|
|
|
|
|
|
|
|
|
|
|
var req DictionaryRequest
|
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if req.Name == "" {
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "Имя словаря обязательно"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result, err := a.DB.Exec(`
|
|
|
|
|
|
UPDATE dictionaries
|
|
|
|
|
|
SET name = $1
|
|
|
|
|
|
WHERE id = $2
|
|
|
|
|
|
`, req.Name, dictionaryID)
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
|
|
dictionaryID := vars["id"]
|
|
|
|
|
|
|
|
|
|
|
|
// Prevent deletion of default dictionary (id = 0)
|
|
|
|
|
|
if dictionaryID == "0" {
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{"error": "Cannot delete default dictionary"})
|
|
|
|
|
|
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" {
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Get configs
|
|
|
|
|
|
configsQuery := `
|
|
|
|
|
|
SELECT id, name, words_count, max_cards, try_message
|
|
|
|
|
|
FROM configs
|
|
|
|
|
|
ORDER BY id
|
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
|
|
configsRows, err := a.DB.Query(configsQuery)
|
|
|
|
|
|
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
|
|
|
|
|
|
GROUP BY d.id, d.name
|
|
|
|
|
|
ORDER BY d.id
|
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
|
|
dictsRows, err := a.DB.Query(dictsQuery)
|
|
|
|
|
|
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" {
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var req ConfigRequest
|
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if req.Name == "" {
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{"message": "Имя обязательно для заполнения"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.WordsCount <= 0 {
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{"message": "Количество слов должно быть больше 0"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
tx, err := a.DB.Begin()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
|
|
var id int
|
|
|
|
|
|
err = tx.QueryRow(`
|
|
|
|
|
|
INSERT INTO configs (name, words_count, max_cards, try_message)
|
|
|
|
|
|
VALUES ($1, $2, $3, $4)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage).Scan(&id)
|
|
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Insert dictionary associations if provided
|
|
|
|
|
|
if len(req.DictionaryIDs) > 0 {
|
|
|
|
|
|
stmt, err := tx.Prepare(`
|
|
|
|
|
|
INSERT INTO config_dictionaries (config_id, dictionary_id)
|
|
|
|
|
|
VALUES ($1, $2)
|
|
|
|
|
|
`)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer stmt.Close()
|
|
|
|
|
|
|
|
|
|
|
|
for _, dictID := range req.DictionaryIDs {
|
|
|
|
|
|
_, err := stmt.Exec(id, dictID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
|
|
|
"message": "Config created successfully",
|
|
|
|
|
|
"id": id,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if r.Method == "OPTIONS" {
|
|
|
|
|
|
setCORSHeaders(w)
|
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setCORSHeaders(w)
|
|
|
|
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
|
|
configID := vars["id"]
|
|
|
|
|
|
|
|
|
|
|
|
var req ConfigRequest
|
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if req.Name == "" {
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{"message": "Имя обязательно для заполнения"})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if req.WordsCount <= 0 {
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{"message": "Количество слов должно быть больше 0"})
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
|
|
configID := vars["id"]
|
|
|
|
|
|
|
|
|
|
|
|
result, err := a.DB.Exec("DELETE FROM configs WHERE id = $1", configID)
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
log.Printf("getWeeklyStatsHandler called from %s, path: %s", r.RemoteAddr, r.URL.Path)
|
|
|
|
|
|
|
|
|
|
|
|
// Опционально обновляем 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
|
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)
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Пытаемся настроить webhook автоматически при старте, если есть base URL и bot token в БД
|
|
|
|
|
|
webhookBaseURL := getEnv("WEBHOOK_BASE_URL", "")
|
|
|
|
|
|
if webhookBaseURL != "" {
|
|
|
|
|
|
integration, err := app.getTelegramIntegration()
|
|
|
|
|
|
if err == nil && integration.BotToken != nil && *integration.BotToken != "" {
|
|
|
|
|
|
webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram"
|
2025-12-31 19:39:01 +03:00
|
|
|
|
log.Printf("Attempting to setup Telegram webhook at startup. WEBHOOK_BASE_URL='%s'", webhookBaseURL)
|
2025-12-31 19:11:28 +03:00
|
|
|
|
if err := setupTelegramWebhook(*integration.BotToken, webhookURL); err != nil {
|
|
|
|
|
|
log.Printf("Warning: Failed to setup Telegram webhook at startup: %v. Webhook will be configured when user saves bot token.", err)
|
2025-12-29 20:01:55 +03:00
|
|
|
|
} else {
|
2025-12-31 19:39:01 +03:00
|
|
|
|
log.Printf("SUCCESS: Telegram webhook configured successfully at startup: %s", webhookURL)
|
2025-12-29 20:01:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2025-12-31 19:11:28 +03:00
|
|
|
|
log.Printf("Telegram bot token not found in database. Webhook will be configured when user saves bot token.")
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
// Запускаем планировщик для автоматической фиксации целей на неделю
|
|
|
|
|
|
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()
|
|
|
|
|
|
r.HandleFunc("/api/words", app.getWordsHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/words", app.addWordsHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/test/words", app.getTestWordsHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/test/progress", app.updateTestProgressHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/configs", app.getConfigsHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/configs", app.addConfigHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/configs/{id}", app.updateConfigHandler).Methods("PUT", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/configs/{id}", app.deleteConfigHandler).Methods("DELETE", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/configs/{id}/dictionaries", app.getConfigDictionariesHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/dictionaries", app.getDictionariesHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/dictionaries", app.addDictionaryHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/dictionaries/{id}", app.updateDictionaryHandler).Methods("PUT", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/dictionaries/{id}", app.deleteDictionaryHandler).Methods("DELETE", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/test-configs-and-dictionaries", app.getTestConfigsAndDictionariesHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/weekly-stats", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/playlife-feed", app.getWeeklyStatsHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/message/post", app.messagePostHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/weekly_goals/setup", app.weeklyGoalsSetupHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS")
|
2025-12-29 21:31:43 +03:00
|
|
|
|
r.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS")
|
2025-12-29 20:01:55 +03:00
|
|
|
|
r.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/admin", app.adminHandler).Methods("GET")
|
|
|
|
|
|
r.HandleFunc("/admin.html", app.adminHandler).Methods("GET")
|
2025-12-29 20:58:34 +03:00
|
|
|
|
r.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
|
2025-12-31 19:11:28 +03:00
|
|
|
|
r.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "OPTIONS")
|
|
|
|
|
|
r.HandleFunc("/api/integrations/todoist/webhook-url", app.getTodoistWebhookURLHandler).Methods("GET", "OPTIONS")
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
|
|
|
|
|
port := getEnv("PORT", "8080")
|
|
|
|
|
|
log.Printf("Server starting on port %s", port)
|
|
|
|
|
|
log.Printf("Registered routes: /api/words (GET, POST), /api/test/words (GET), /api/test/progress (POST), /api/configs (GET, POST, PUT, DELETE), /api/dictionaries (GET, POST, PUT, DELETE), /api/test-configs-and-dictionaries (GET), /api/weekly-stats (GET), /playlife-feed (GET), /message/post (POST), /webhook/message/post (POST), /webhook/todoist (POST), /webhook/telegram (POST), /weekly_goals/setup (POST), /daily-report/trigger (POST), /projects (GET), /project/priority (POST), /d2dc349a-0d13-49b2-a8f0-1ab094bfba9b (GET), /admin (GET)")
|
|
|
|
|
|
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 {
|
|
|
|
|
|
ID int `json:"id"`
|
|
|
|
|
|
ChatID *string `json:"chat_id"`
|
|
|
|
|
|
BotToken *string `json:"bot_token"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// getTelegramIntegration получает telegram интеграцию из БД
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
func (a *App) processTelegramMessage(fullText string, entities []TelegramEntity) (*ProcessedEntry, error) {
|
|
|
|
|
|
fullText = strings.TrimSpace(fullText)
|
|
|
|
|
|
|
|
|
|
|
|
// Регулярное выражение: project+/-score (без **)
|
|
|
|
|
|
scoreRegex := regexp.MustCompile(`^([а-яА-ЯёЁ\w]+)([+-])(\d+(?:\.\d+)?)$`)
|
|
|
|
|
|
|
|
|
|
|
|
// Массив для хранения извлеченных элементов {project, score}
|
|
|
|
|
|
scoreNodes := make([]ProcessedNode, 0)
|
|
|
|
|
|
workingText := fullText
|
|
|
|
|
|
placeholderIndex := 0
|
|
|
|
|
|
|
|
|
|
|
|
// Находим все элементы, выделенные жирным шрифтом
|
|
|
|
|
|
boldEntities := make([]TelegramEntity, 0)
|
|
|
|
|
|
for _, entity := range entities {
|
|
|
|
|
|
if entity.Type == "bold" {
|
|
|
|
|
|
boldEntities = append(boldEntities, entity)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Сортируем в ПРЯМОМ порядке (по offset), чтобы гарантировать, что ${0} соответствует первому в тексте
|
|
|
|
|
|
sort.Slice(boldEntities, func(i, j int) bool {
|
|
|
|
|
|
return boldEntities[i].Offset < boldEntities[j].Offset
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Массив для хранения данных, которые будут использоваться для замены в обратном порядке
|
|
|
|
|
|
type ReplacementData struct {
|
|
|
|
|
|
Start int
|
|
|
|
|
|
Length int
|
|
|
|
|
|
Placeholder string
|
|
|
|
|
|
}
|
|
|
|
|
|
replacementData := make([]ReplacementData, 0)
|
|
|
|
|
|
|
|
|
|
|
|
for _, entity := range boldEntities {
|
|
|
|
|
|
// Telegram использует UTF-16 для offset и length, конвертируем в UTF-8 байты
|
|
|
|
|
|
start := utf16OffsetToUTF8(fullText, entity.Offset)
|
|
|
|
|
|
length := utf16LengthToUTF8(fullText, entity.Offset, entity.Length)
|
|
|
|
|
|
|
|
|
|
|
|
// Извлекаем чистый жирный текст
|
|
|
|
|
|
if start+length > len(fullText) {
|
|
|
|
|
|
continue // Пропускаем некорректные entities
|
|
|
|
|
|
}
|
|
|
|
|
|
boldText := strings.TrimSpace(fullText[start : start+length])
|
|
|
|
|
|
|
|
|
|
|
|
// Проверяем соответствие формату
|
|
|
|
|
|
match := scoreRegex.FindStringSubmatch(boldText)
|
|
|
|
|
|
|
|
|
|
|
|
if match != nil && len(match) == 4 {
|
|
|
|
|
|
// Создаем элемент node
|
|
|
|
|
|
project := match[1]
|
|
|
|
|
|
sign := match[2]
|
|
|
|
|
|
rawScore, err := strconv.ParseFloat(match[3], 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Error parsing score: %v", err)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
score := rawScore
|
|
|
|
|
|
if sign == "-" {
|
|
|
|
|
|
score = -rawScore
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Добавляем в массив nodes (по порядку)
|
|
|
|
|
|
scoreNodes = append(scoreNodes, ProcessedNode{
|
|
|
|
|
|
Project: project,
|
|
|
|
|
|
Score: score,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Создаем данные для замены
|
|
|
|
|
|
replacementData = append(replacementData, ReplacementData{
|
|
|
|
|
|
Start: start,
|
|
|
|
|
|
Length: length,
|
|
|
|
|
|
Placeholder: fmt.Sprintf("${%d}", placeholderIndex),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
placeholderIndex++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Теперь выполняем замены в ОБРАТНОМ порядке, чтобы offset не "смещались"
|
|
|
|
|
|
sort.Slice(replacementData, func(i, j int) bool {
|
|
|
|
|
|
return replacementData[i].Start > replacementData[j].Start
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
for _, item := range replacementData {
|
|
|
|
|
|
// Заменяем сегмент в workingText, используя оригинальные offset и length
|
|
|
|
|
|
if item.Start+item.Length <= len(workingText) {
|
|
|
|
|
|
workingText = workingText[:item.Start] + item.Placeholder + workingText[item.Start+item.Length:]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Удаляем пустые строки и лишние пробелы
|
|
|
|
|
|
lines := strings.Split(workingText, "\n")
|
|
|
|
|
|
cleanedLines := make([]string, 0)
|
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
|
|
if trimmed != "" {
|
|
|
|
|
|
cleanedLines = append(cleanedLines, trimmed)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
processedText := strings.Join(cleanedLines, "\n")
|
|
|
|
|
|
|
|
|
|
|
|
// Используем текущее время в формате ISO 8601 (UTC)
|
|
|
|
|
|
createdDate := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
|
|
|
|
|
|
// Вставляем данные в БД только если есть nodes
|
|
|
|
|
|
if len(scoreNodes) > 0 {
|
|
|
|
|
|
err := a.insertMessageData(processedText, createdDate, scoreNodes)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Error inserting message data: %v", err)
|
|
|
|
|
|
return nil, fmt.Errorf("error inserting data: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Если nodes нет, используем исходный текст для processedText
|
|
|
|
|
|
processedText = fullText
|
|
|
|
|
|
log.Printf("No nodes found in Telegram message, message will not be saved to database")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Формируем ответ
|
|
|
|
|
|
response := &ProcessedEntry{
|
|
|
|
|
|
Text: processedText,
|
|
|
|
|
|
CreatedDate: createdDate,
|
|
|
|
|
|
Nodes: scoreNodes,
|
|
|
|
|
|
Raw: fullText,
|
|
|
|
|
|
Markdown: fullText, // Для Telegram markdown не нужен
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// НЕ отправляем сообщение обратно в Telegram (в отличие от processMessage)
|
|
|
|
|
|
|
|
|
|
|
|
return response, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// processMessage обрабатывает текст сообщения: парсит ноды, сохраняет в БД и отправляет в Telegram
|
|
|
|
|
|
func (a *App) processMessage(rawText string) (*ProcessedEntry, error) {
|
|
|
|
|
|
return a.processMessageInternal(rawText, true)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// processMessageWithoutTelegram обрабатывает текст сообщения: парсит ноды, сохраняет в БД, но НЕ отправляет в Telegram
|
|
|
|
|
|
func (a *App) processMessageWithoutTelegram(rawText string) (*ProcessedEntry, error) {
|
|
|
|
|
|
return a.processMessageInternal(rawText, false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// processMessageInternal - внутренняя функция обработки сообщения
|
|
|
|
|
|
// sendToTelegram определяет, нужно ли отправлять сообщение в Telegram
|
|
|
|
|
|
func (a *App) processMessageInternal(rawText string, sendToTelegram bool) (*ProcessedEntry, error) {
|
|
|
|
|
|
rawText = strings.TrimSpace(rawText)
|
|
|
|
|
|
|
|
|
|
|
|
// Регулярное выражение для поиска **[Project][+| -][Score]**
|
|
|
|
|
|
regex := regexp.MustCompile(`\*\*(.+?)([+-])([\d.]+)\*\*`)
|
|
|
|
|
|
|
|
|
|
|
|
nodes := make([]ProcessedNode, 0)
|
|
|
|
|
|
nodeCounter := 0
|
|
|
|
|
|
|
|
|
|
|
|
// Ищем все node и заменяем их в тексте на плейсхолдеры ${0}, ${1} и т.д.
|
|
|
|
|
|
processedText := regex.ReplaceAllStringFunc(rawText, func(fullMatch string) string {
|
|
|
|
|
|
matches := regex.FindStringSubmatch(fullMatch)
|
|
|
|
|
|
if len(matches) != 4 {
|
|
|
|
|
|
return fullMatch
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
projectName := strings.TrimSpace(matches[1])
|
|
|
|
|
|
sign := matches[2]
|
|
|
|
|
|
scoreString := matches[3]
|
|
|
|
|
|
|
|
|
|
|
|
score, err := strconv.ParseFloat(scoreString, 64)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Error parsing score: %v", err)
|
|
|
|
|
|
return fullMatch
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if sign == "-" {
|
|
|
|
|
|
score = -score
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Добавляем данные в массив nodes
|
|
|
|
|
|
nodes = append(nodes, ProcessedNode{
|
|
|
|
|
|
Project: projectName,
|
|
|
|
|
|
Score: score,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
placeholder := fmt.Sprintf("${%d}", nodeCounter)
|
|
|
|
|
|
nodeCounter++
|
|
|
|
|
|
return placeholder
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Удаляем пустые строки и лишние пробелы
|
|
|
|
|
|
lines := strings.Split(processedText, "\n")
|
|
|
|
|
|
cleanedLines := make([]string, 0)
|
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
|
|
if trimmed != "" {
|
|
|
|
|
|
cleanedLines = append(cleanedLines, trimmed)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
processedText = strings.Join(cleanedLines, "\n")
|
|
|
|
|
|
|
|
|
|
|
|
// Формируем Markdown (Legacy) контент: заменяем ** на *
|
|
|
|
|
|
markdownText := strings.ReplaceAll(rawText, "**", "*")
|
|
|
|
|
|
|
|
|
|
|
|
// Используем текущее время
|
|
|
|
|
|
createdDate := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
|
|
|
|
|
|
|
// Вставляем данные в БД только если есть nodes
|
|
|
|
|
|
if len(nodes) > 0 {
|
|
|
|
|
|
err := a.insertMessageData(processedText, createdDate, nodes)
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
// Парсим входящий запрос - может быть как {body: {text: ...}}, так и {text: ...}
|
|
|
|
|
|
var rawReq map[string]interface{}
|
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&rawReq); err != nil {
|
|
|
|
|
|
log.Printf("Error decoding message post request: %v", err)
|
|
|
|
|
|
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Извлекаем text из разных возможных структур
|
|
|
|
|
|
var rawText string
|
|
|
|
|
|
if body, ok := rawReq["body"].(map[string]interface{}); ok {
|
|
|
|
|
|
if text, ok := body["text"].(string); ok {
|
|
|
|
|
|
rawText = text
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Если не нашли в body, пробуем напрямую
|
|
|
|
|
|
if rawText == "" {
|
|
|
|
|
|
if text, ok := rawReq["text"].(string); ok {
|
|
|
|
|
|
rawText = text
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Проверка на наличие нужного поля
|
|
|
|
|
|
if rawText == "" {
|
|
|
|
|
|
sendErrorWithCORS(w, "Missing 'text' field in body", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Обрабатываем сообщение
|
|
|
|
|
|
response, err := a.processMessage(rawText)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Error processing message: %v", err)
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
json.NewEncoder(w).Encode(response)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) insertMessageData(entryText string, createdDate string, nodes []ProcessedNode) error {
|
|
|
|
|
|
// Начинаем транзакцию
|
|
|
|
|
|
tx, err := a.DB.Begin()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
|
|
// 1. UPSERT проектов
|
|
|
|
|
|
projectNames := make(map[string]bool)
|
|
|
|
|
|
for _, node := range nodes {
|
|
|
|
|
|
projectNames[node.Project] = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Вставляем проекты
|
|
|
|
|
|
for projectName := range projectNames {
|
|
|
|
|
|
_, err := tx.Exec(`
|
2025-12-29 21:31:43 +03:00
|
|
|
|
INSERT INTO projects (name, deleted)
|
|
|
|
|
|
VALUES ($1, FALSE)
|
2025-12-29 20:01:55 +03:00
|
|
|
|
ON CONFLICT (name) DO UPDATE
|
2025-12-29 21:31:43 +03:00
|
|
|
|
SET name = EXCLUDED.name, deleted = FALSE
|
2025-12-29 20:01:55 +03:00
|
|
|
|
`, projectName)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to upsert project %s: %w", projectName, err)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Вставляем entry
|
|
|
|
|
|
var entryID int
|
|
|
|
|
|
err = tx.QueryRow(`
|
|
|
|
|
|
INSERT INTO entries (text, created_date)
|
|
|
|
|
|
VALUES ($1, $2)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
`, entryText, createdDate).Scan(&entryID)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to insert entry: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Вставляем nodes
|
|
|
|
|
|
for _, node := range nodes {
|
|
|
|
|
|
_, 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)
|
|
|
|
|
|
|
|
|
|
|
|
query := `
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
id AS project_id,
|
|
|
|
|
|
name AS project_name,
|
|
|
|
|
|
priority
|
|
|
|
|
|
FROM
|
|
|
|
|
|
projects
|
2025-12-29 21:31:43 +03:00
|
|
|
|
WHERE
|
|
|
|
|
|
deleted = FALSE
|
2025-12-29 20:01:55 +03:00
|
|
|
|
ORDER BY
|
|
|
|
|
|
priority ASC NULLS LAST,
|
|
|
|
|
|
project_name
|
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
|
|
rows, err := a.DB.Query(query)
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
// Читаем тело запроса один раз
|
|
|
|
|
|
bodyBytes, err := io.ReadAll(r.Body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Error reading request body: %v", err)
|
|
|
|
|
|
sendErrorWithCORS(w, "Error reading request body", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer r.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
// Парсим входящий запрос - может быть как {body: [...]}, так и просто массив
|
|
|
|
|
|
var projectsToUpdate []ProjectPriorityUpdate
|
|
|
|
|
|
|
|
|
|
|
|
// Сначала пробуем декодировать как прямой массив
|
|
|
|
|
|
var directArray []interface{}
|
|
|
|
|
|
arrayErr := json.Unmarshal(bodyBytes, &directArray)
|
|
|
|
|
|
if arrayErr == nil && len(directArray) > 0 {
|
|
|
|
|
|
// Успешно декодировали как массив
|
|
|
|
|
|
log.Printf("Received direct array format with %d items", len(directArray))
|
|
|
|
|
|
for _, item := range directArray {
|
|
|
|
|
|
if itemMap, ok := item.(map[string]interface{}); ok {
|
|
|
|
|
|
var project ProjectPriorityUpdate
|
|
|
|
|
|
|
|
|
|
|
|
// Извлекаем id
|
|
|
|
|
|
if idVal, ok := itemMap["id"].(float64); ok {
|
|
|
|
|
|
project.ID = int(idVal)
|
|
|
|
|
|
} else if idVal, ok := itemMap["id"].(int); ok {
|
|
|
|
|
|
project.ID = idVal
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Invalid id in request item: %v", itemMap)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Извлекаем priority (может быть null, undefined, или числом)
|
|
|
|
|
|
if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil {
|
|
|
|
|
|
// Проверяем, не является ли это строкой "null"
|
|
|
|
|
|
if strVal, ok := priorityVal.(string); ok && (strVal == "null" || strVal == "NULL") {
|
|
|
|
|
|
project.Priority = nil
|
|
|
|
|
|
} else if numVal, ok := priorityVal.(float64); ok {
|
|
|
|
|
|
priorityInt := int(numVal)
|
|
|
|
|
|
project.Priority = &priorityInt
|
|
|
|
|
|
} else if numVal, ok := priorityVal.(int); ok {
|
|
|
|
|
|
project.Priority = &numVal
|
|
|
|
|
|
} else {
|
|
|
|
|
|
project.Priority = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
project.Priority = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
projectsToUpdate = append(projectsToUpdate, project)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Если не получилось как массив (ошибка декодирования), пробуем как объект с body
|
|
|
|
|
|
// НЕ пытаемся декодировать как объект, если массив декодировался успешно (даже если пустой)
|
|
|
|
|
|
if len(projectsToUpdate) == 0 && arrayErr != nil {
|
|
|
|
|
|
log.Printf("Failed to decode as array (error: %v), trying as object", arrayErr)
|
|
|
|
|
|
var rawReq map[string]interface{}
|
|
|
|
|
|
if err := json.Unmarshal(bodyBytes, &rawReq); err != nil {
|
|
|
|
|
|
log.Printf("Error decoding project priority request as object: %v, body: %s", err, string(bodyBytes))
|
|
|
|
|
|
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Извлекаем массив проектов из body
|
|
|
|
|
|
if body, ok := rawReq["body"].([]interface{}); ok {
|
|
|
|
|
|
log.Printf("Received body format with %d items", len(body))
|
|
|
|
|
|
for _, item := range body {
|
|
|
|
|
|
if itemMap, ok := item.(map[string]interface{}); ok {
|
|
|
|
|
|
var project ProjectPriorityUpdate
|
|
|
|
|
|
|
|
|
|
|
|
// Извлекаем id
|
|
|
|
|
|
if idVal, ok := itemMap["id"].(float64); ok {
|
|
|
|
|
|
project.ID = int(idVal)
|
|
|
|
|
|
} else if idVal, ok := itemMap["id"].(int); ok {
|
|
|
|
|
|
project.ID = idVal
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Invalid id in request item: %v", itemMap)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Извлекаем priority (может быть null, undefined, или числом)
|
|
|
|
|
|
if priorityVal, ok := itemMap["priority"]; ok && priorityVal != nil {
|
|
|
|
|
|
// Проверяем, не является ли это строкой "null"
|
|
|
|
|
|
if strVal, ok := priorityVal.(string); ok && (strVal == "null" || strVal == "NULL") {
|
|
|
|
|
|
project.Priority = nil
|
|
|
|
|
|
} else if numVal, ok := priorityVal.(float64); ok {
|
|
|
|
|
|
priorityInt := int(numVal)
|
|
|
|
|
|
project.Priority = &priorityInt
|
|
|
|
|
|
} else if numVal, ok := priorityVal.(int); ok {
|
|
|
|
|
|
project.Priority = &numVal
|
|
|
|
|
|
} else {
|
|
|
|
|
|
project.Priority = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
project.Priority = nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
projectsToUpdate = append(projectsToUpdate, project)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(projectsToUpdate) == 0 {
|
|
|
|
|
|
log.Printf("No projects to update after parsing. Body was: %s", string(bodyBytes))
|
|
|
|
|
|
sendErrorWithCORS(w, "No projects to update", http.StatusBadRequest)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log.Printf("Successfully parsed %d projects to update", len(projectsToUpdate))
|
|
|
|
|
|
|
|
|
|
|
|
// Начинаем транзакцию
|
|
|
|
|
|
tx, err := a.DB.Begin()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Error beginning transaction: %v", err)
|
|
|
|
|
|
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
|
|
// Обновляем приоритеты для каждого проекта
|
|
|
|
|
|
for _, project := range projectsToUpdate {
|
|
|
|
|
|
if project.Priority == nil {
|
|
|
|
|
|
_, err = tx.Exec(`
|
|
|
|
|
|
UPDATE projects
|
|
|
|
|
|
SET priority = NULL
|
|
|
|
|
|
WHERE id = $1
|
|
|
|
|
|
`, project.ID)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_, err = tx.Exec(`
|
|
|
|
|
|
UPDATE projects
|
|
|
|
|
|
SET priority = $1
|
|
|
|
|
|
WHERE id = $2
|
|
|
|
|
|
`, *project.Priority, project.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Начинаем транзакцию
|
|
|
|
|
|
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())
|
|
|
|
|
|
log.Printf("RemoteAddr: %s", r.RemoteAddr)
|
|
|
|
|
|
log.Printf("Headers:")
|
|
|
|
|
|
for key, values := range r.Header {
|
|
|
|
|
|
for _, value := range values {
|
|
|
|
|
|
log.Printf(" %s: %s", key, value)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if r.Method == "OPTIONS" {
|
|
|
|
|
|
log.Printf("OPTIONS request, returning OK")
|
|
|
|
|
|
setCORSHeaders(w)
|
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setCORSHeaders(w)
|
|
|
|
|
|
|
|
|
|
|
|
// Читаем тело запроса для логирования
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Логируем сырое тело запроса
|
|
|
|
|
|
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))
|
|
|
|
|
|
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
|
|
|
|
|
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))
|
|
|
|
|
|
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
|
|
|
|
|
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))
|
|
|
|
|
|
sendErrorWithCORS(w, "Missing 'content' or 'description' in event_data", http.StatusBadRequest)
|
|
|
|
|
|
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)
|
|
|
|
|
|
log.Printf("Calling processMessageWithoutTelegram with combined text...")
|
|
|
|
|
|
response, err := a.processMessageWithoutTelegram(combinedText)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("ERROR processing Todoist message: %v", err)
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
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")
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
|
|
|
|
|
"message": "Task processed successfully",
|
|
|
|
|
|
"result": response,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (a *App) telegramWebhookHandler(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
|
if r.Method == "OPTIONS" {
|
|
|
|
|
|
setCORSHeaders(w)
|
|
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setCORSHeaders(w)
|
|
|
|
|
|
|
|
|
|
|
|
// Парсим webhook от Telegram
|
|
|
|
|
|
var update TelegramUpdate
|
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
|
|
|
|
|
log.Printf("Error decoding Telegram webhook: %v", err)
|
|
|
|
|
|
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
|
|
|
|
|
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")
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
|
|
|
|
"message": "No message found in update",
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log.Printf("Telegram webhook: message present, chat_id=%d", message.Chat.ID)
|
|
|
|
|
|
|
|
|
|
|
|
// Сохраняем chat_id при первом сообщении (даже если нет текста)
|
|
|
|
|
|
if message.Chat.ID != 0 {
|
|
|
|
|
|
chatIDStr := strconv.FormatInt(message.Chat.ID, 10)
|
|
|
|
|
|
log.Printf("Processing chat_id: %s", chatIDStr)
|
2025-12-31 19:11:28 +03:00
|
|
|
|
integration, err := a.getTelegramIntegration()
|
2025-12-31 19:39:01 +03:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Error getting telegram integration: %v", err)
|
|
|
|
|
|
} else {
|
2025-12-31 19:11:28 +03:00
|
|
|
|
// Сохраняем chat_id, если его еще нет
|
|
|
|
|
|
if integration.ChatID == nil || *integration.ChatID == "" {
|
2025-12-31 19:39:01 +03:00
|
|
|
|
log.Printf("Attempting to save chat_id: %s", chatIDStr)
|
2025-12-31 19:11:28 +03:00
|
|
|
|
if err := a.saveTelegramChatID(chatIDStr); err != nil {
|
|
|
|
|
|
log.Printf("Warning: Failed to save chat_id: %v", err)
|
|
|
|
|
|
} else {
|
2025-12-31 19:39:01 +03:00
|
|
|
|
log.Printf("Successfully saved chat_id from first message: %s", chatIDStr)
|
2025-12-31 19:11:28 +03:00
|
|
|
|
}
|
2025-12-31 19:39:01 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Chat_id already exists in database: %s", *integration.ChatID)
|
2025-12-31 19:11:28 +03:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-31 19:39:01 +03:00
|
|
|
|
} else {
|
|
|
|
|
|
log.Printf("Warning: message.Chat.ID is 0, cannot save chat_id")
|
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")
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
|
|
|
|
"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{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
log.Printf("Processing Telegram message: text='%s', entities count=%d", fullText, len(entities))
|
|
|
|
|
|
|
|
|
|
|
|
// Обрабатываем сообщение через новую логику (с entities, без отправки обратно в Telegram)
|
|
|
|
|
|
response, err := a.processTelegramMessage(fullText, entities)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
log.Printf("Error processing Telegram message: %v", err)
|
|
|
|
|
|
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
|
|
|
|
|
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{}{
|
|
|
|
|
|
"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)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
p.deleted = FALSE
|
2025-12-29 20:01:55 +03:00
|
|
|
|
ORDER BY
|
|
|
|
|
|
report_year DESC,
|
|
|
|
|
|
report_week DESC,
|
|
|
|
|
|
project_name
|
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
|
|
rows, err := a.DB.Query(query)
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
integration, err := a.getTelegramIntegration()
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := a.saveTelegramBotToken(req.BotToken); err != nil {
|
|
|
|
|
|
sendErrorWithCORS(w, fmt.Sprintf("Failed to save bot token: %v", err), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Настраиваем 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)
|
2025-12-31 19:11:28 +03:00
|
|
|
|
if webhookBaseURL != "" {
|
|
|
|
|
|
webhookURL := strings.TrimRight(webhookBaseURL, "/") + "/webhook/telegram"
|
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 {
|
2025-12-31 19:39:01 +03:00
|
|
|
|
log.Printf("WARNING: WEBHOOK_BASE_URL not set. Webhook will not be configured automatically.")
|
2025-12-31 19:11:28 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
integration, err := a.getTelegramIntegration()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
sendErrorWithCORS(w, fmt.Sprintf("Failed to get updated integration: %v", err), http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
// Получаем base URL из env
|
|
|
|
|
|
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
|
|
|
|
|
if baseURL == "" {
|
|
|
|
|
|
sendErrorWithCORS(w, "WEBHOOK_BASE_URL not configured", http.StatusInternalServerError)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist"
|
|
|
|
|
|
|
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
|
|
|
|
"webhook_url": webhookURL,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|