Files
play-life/play-life-backend/main.go
poignatov 8d79a8f692 Fix: Исправление определения недели на границе года (ISOYEAR)
- Заменен EXTRACT(YEAR) на EXTRACT(ISOYEAR) в materialized view для корректной работы на границе года
- Обновлена миграция 001_create_schema.sql для использования ISOYEAR
- Создана миграция 006_fix_weekly_report_mv_structure.sql для исправления структуры view (LEFT JOIN)
- Добавлен endpoint /admin/recreate-mv для пересоздания materialized view
- Обновлена документация миграций в README.md
- Обновлены зависимости Go (go.mod, go.sum)
2025-12-29 20:58:34 +03:00

3743 lines
114 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"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"`
}
type TelegramMessage struct {
Text string `json:"text"`
Entities []TelegramEntity `json:"entities"`
}
type TelegramWebhook struct {
Message TelegramMessage `json:"message"`
}
// TelegramUpdate - структура для Telegram webhook (обычно это Update объект)
type TelegramUpdate struct {
UpdateID int `json:"update_id"`
Message TelegramMessage `json:"message"`
}
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,
wg.priority
FROM
weekly_goals wg
JOIN
projects p ON wg.project_id = p.id
LEFT JOIN
weekly_report_mv wr
ON wg.project_id = wr.project_id
AND wg.goal_year = wr.report_year
AND wg.goal_week = wr.report_week
WHERE
-- Фильтруем ТОЛЬКО по целям текущего года и недели
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
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
var maxGoalScore sql.NullFloat64
var priority sql.NullInt64
err := rows.Scan(
&project.ProjectName,
&project.TotalScore,
&project.MinGoalScore,
&maxGoalScore,
&priority,
)
if err != nil {
log.Printf("Error scanning weekly stats row: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
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
minGoalScore := project.MinGoalScore
var maxGoalScoreVal float64
if project.MaxGoalScore != nil {
maxGoalScoreVal = *project.MaxGoalScore
}
// Параметры бонуса в зависимости от priority
var extraBonusLimit float64 = 20
if priorityVal == 1 {
extraBonusLimit = 50
} else if priorityVal == 2 {
extraBonusLimit = 35
}
// Расчет базового прогресса
var baseProgress float64
if minGoalScore > 0 {
baseProgress = (min(totalScore, minGoalScore) / minGoalScore) * 100.0
}
// Расчет экстра прогресса
var extraProgress float64
denominator := maxGoalScoreVal - minGoalScore
if denominator > 0 && totalScore > minGoalScore {
excess := min(totalScore, maxGoalScoreVal) - minGoalScore
extraProgress = (excess / denominator) * extraBonusLimit
}
resultScore := baseProgress + extraProgress
project.CalculatedScore = roundToTwoDecimals(resultScore)
// Группировка для итогового расчета
if _, exists := groups[priorityVal]; !exists {
groups[priorityVal] = make([]float64, 0)
}
groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore)
projects = append(projects, project)
}
// Находим среднее внутри каждой группы
groupAverages := make([]float64, 0)
for _, scores := range groups {
if len(scores) > 0 {
sum := 0.0
for _, score := range scores {
sum += score
}
avg := sum / float64(len(scores))
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)
}
if _, err := a.DB.Exec(createEntriesTable); err != nil {
return fmt.Errorf("failed to create entries table: %w", err)
}
if _, err := a.DB.Exec(createNodesTable); err != nil {
return fmt.Errorf("failed to create nodes table: %w", err)
}
for _, indexSQL := range createNodesIndexes {
if _, err := a.DB.Exec(indexSQL); err != nil {
log.Printf("Warning: Failed to create index: %v", err)
}
}
if _, err := a.DB.Exec(createWeeklyGoalsTable); err != nil {
return fmt.Errorf("failed to create weekly_goals table: %w", err)
}
if _, err := a.DB.Exec(createWeeklyGoalsIndex); err != nil {
log.Printf("Warning: Failed to create weekly_goals index: %v", err)
}
// Создаем materialized view (может потребоваться удаление старого, если он существует)
dropMaterializedView := `DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv`
a.DB.Exec(dropMaterializedView) // Игнорируем ошибку, если view не существует
createMaterializedView := `
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
JOIN
entries e ON n.entry_id = e.id
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
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)
}
return nil
}
// startWeeklyGoalsScheduler запускает планировщик для автоматической фиксации целей на неделю
// каждый понедельник в 6:00 утра в указанном часовом поясе
func (a *App) startWeeklyGoalsScheduler() {
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
timezoneStr := getEnv("TIMEZONE", "UTC")
// Загружаем часовой пояс
loc, err := time.LoadLocation(timezoneStr)
if err != nil {
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
loc = time.UTC
} else {
log.Printf("Scheduler timezone set to: %s", timezoneStr)
}
// Создаем планировщик с указанным часовым поясом
c := cron.New(cron.WithLocation(loc))
// Добавляем задачу: каждый понедельник в 6:00 утра
// Cron выражение: "0 6 * * 1" означает: минута=0, час=6, любой день месяца, любой месяц, понедельник (1)
_, err = c.AddFunc("0 6 * * 1", func() {
log.Printf("Scheduled task: Setting up weekly goals (timezone: %s)", timezoneStr)
if err := a.setupWeeklyGoals(); err != nil {
log.Printf("Error in scheduled weekly goals setup: %v", err)
}
})
if err != nil {
log.Printf("Error adding cron job for weekly goals: %v", err)
return
}
// Запускаем планировщик
c.Start()
log.Println("Weekly goals scheduler started: every Monday at 6:00 AM")
// Планировщик будет работать в фоновом режиме
}
// getWeeklyStatsData получает данные о проектах и их целях (без HTTP обработки)
func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
// Обновляем materialized view перед запросом
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
if err != nil {
log.Printf("Warning: Failed to refresh materialized view: %v", err)
// Продолжаем выполнение даже если обновление не удалось
}
query := `
SELECT
p.name AS project_name,
-- Используем COALESCE для установки total_score в 0.0000, если нет данных в weekly_report_mv
COALESCE(wr.total_score, 0.0000) AS total_score,
wg.min_goal_score,
wg.max_goal_score,
wg.priority
FROM
weekly_goals wg
JOIN
projects p ON wg.project_id = p.id
LEFT JOIN
weekly_report_mv wr
ON wg.project_id = wr.project_id
AND wg.goal_year = wr.report_year
AND wg.goal_week = wr.report_week
WHERE
-- Фильтруем ТОЛЬКО по целям текущего года и недели
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
ORDER BY
total_score DESC
`
rows, err := a.DB.Query(query)
if err != nil {
log.Printf("Error querying weekly stats: %v", err)
return nil, fmt.Errorf("error querying weekly stats: %w", err)
}
defer rows.Close()
projects := make([]WeeklyProjectStats, 0)
// Группы для расчета среднего по priority
groups := make(map[int][]float64)
for rows.Next() {
var project WeeklyProjectStats
var maxGoalScore sql.NullFloat64
var priority sql.NullInt64
err := rows.Scan(
&project.ProjectName,
&project.TotalScore,
&project.MinGoalScore,
&maxGoalScore,
&priority,
)
if err != nil {
log.Printf("Error scanning weekly stats row: %v", err)
return nil, fmt.Errorf("error scanning weekly stats row: %w", err)
}
if 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
minGoalScore := project.MinGoalScore
var maxGoalScoreVal float64
if project.MaxGoalScore != nil {
maxGoalScoreVal = *project.MaxGoalScore
}
// Параметры бонуса в зависимости от priority
var extraBonusLimit float64 = 20
if priorityVal == 1 {
extraBonusLimit = 50
} else if priorityVal == 2 {
extraBonusLimit = 35
}
// Расчет базового прогресса
var baseProgress float64
if minGoalScore > 0 {
baseProgress = (min(totalScore, minGoalScore) / minGoalScore) * 100.0
}
// Расчет экстра прогресса
var extraProgress float64
denominator := maxGoalScoreVal - minGoalScore
if denominator > 0 && totalScore > minGoalScore {
excess := min(totalScore, maxGoalScoreVal) - minGoalScore
extraProgress = (excess / denominator) * extraBonusLimit
}
resultScore := baseProgress + extraProgress
project.CalculatedScore = roundToTwoDecimals(resultScore)
// Группировка для итогового расчета
if _, exists := groups[priorityVal]; !exists {
groups[priorityVal] = make([]float64, 0)
}
groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore)
projects = append(projects, project)
}
// Находим среднее внутри каждой группы
groupAverages := make([]float64, 0)
for _, scores := range groups {
if len(scores) > 0 {
sum := 0.0
for _, score := range scores {
sum += score
}
avg := sum / float64(len(scores))
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 запускает планировщик для ежедневного отчета
// каждый день в 11:59 в указанном часовом поясе
func (a *App) startDailyReportScheduler() {
// Получаем часовой пояс из переменной окружения (по умолчанию UTC)
timezoneStr := getEnv("TIMEZONE", "UTC")
// Загружаем часовой пояс
loc, err := time.LoadLocation(timezoneStr)
if err != nil {
log.Printf("Warning: Invalid timezone '%s': %v. Using UTC instead.", timezoneStr, err)
loc = time.UTC
} else {
log.Printf("Daily report scheduler timezone set to: %s", timezoneStr)
}
// Создаем планировщик с указанным часовым поясом
c := cron.New(cron.WithLocation(loc))
// Добавляем задачу: каждый день в 11:59
// Cron выражение: "59 11 * * *" означает: минута=59, час=11, любой день месяца, любой месяц, любой день недели
_, err = c.AddFunc("59 11 * * *", func() {
log.Printf("Scheduled task: Sending daily report (timezone: %s)", timezoneStr)
if err := a.sendDailyReport(); err != nil {
log.Printf("Error in scheduled daily report: %v", err)
}
})
if err != nil {
log.Printf("Error adding cron job for daily report: %v", err)
return
}
// Запускаем планировщик
c.Start()
log.Println("Daily report scheduler started: every day at 11:59 AM")
// Планировщик будет работать в фоновом режиме
}
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()
// Инициализируем Telegram бота (если токен указан)
var telegramBot *tgbotapi.BotAPI
var telegramChatID int64
telegramToken := getEnv("TELEGRAM_BOT_TOKEN", "")
telegramChatIDStr := getEnv("TELEGRAM_CHAT_ID", "")
telegramWebhookBaseURL := getEnv("TELEGRAM_WEBHOOK_BASE_URL", "")
if telegramToken != "" && telegramChatIDStr != "" {
bot, err := tgbotapi.NewBotAPI(telegramToken)
if err != nil {
log.Printf("Warning: Failed to initialize Telegram bot: %v. Telegram notifications will be disabled.", err)
} else {
telegramBot = bot
chatID, err := strconv.ParseInt(telegramChatIDStr, 10, 64)
if err != nil {
log.Printf("Warning: Invalid TELEGRAM_CHAT_ID format: %v. Telegram notifications will be disabled.", err)
telegramBot = nil
} else {
telegramChatID = chatID
log.Printf("Telegram bot initialized successfully. Chat ID: %d", telegramChatID)
}
}
} else {
log.Println("Telegram bot token or chat ID not provided. Telegram notifications disabled.")
}
// Настраиваем webhook для Telegram (если указан base URL)
if telegramToken != "" && telegramWebhookBaseURL != "" {
webhookURL := strings.TrimRight(telegramWebhookBaseURL, "/") + "/webhook/telegram"
if err := setupTelegramWebhook(telegramToken, webhookURL); err != nil {
log.Printf("Warning: Failed to setup Telegram webhook: %v. Webhook will not be configured.", err)
} else {
log.Printf("Telegram webhook configured successfully: %s", webhookURL)
}
} else if telegramToken != "" {
log.Println("TELEGRAM_WEBHOOK_BASE_URL not provided. Telegram webhook will not be configured automatically.")
}
app := &App{
DB: db,
lastWebhookTime: make(map[int]time.Time),
telegramBot: telegramBot,
telegramChatID: telegramChatID,
}
// Инициализируем БД для 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()
// Запускаем планировщик для ежедневного отчета в 11:59
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")
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")
r.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
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)
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 {
return fmt.Errorf("failed to send webhook setup request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
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
}
func (a *App) sendTelegramMessage(text string) {
log.Printf("sendTelegramMessage called with text length: %d", len(text))
log.Printf("Telegram bot status: bot=%v, chatID=%d", a.telegramBot != nil, a.telegramChatID)
if a.telegramBot == nil || a.telegramChatID == 0 {
// Telegram не настроен, пропускаем отправку
log.Printf("WARNING: Telegram bot not initialized (bot=%v, chatID=%d), skipping message send", a.telegramBot != nil, a.telegramChatID)
return
}
// Конвертируем **текст** в *текст* для Markdown (Legacy)
// Markdown (Legacy) использует одинарную звездочку для жирного текста
// Используем регулярное выражение для замены только парных **
telegramText := regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllString(text, "*$1*")
log.Printf("Sending Telegram message (converted text length: %d): %s", len(telegramText), telegramText)
msg := tgbotapi.NewMessage(a.telegramChatID, telegramText)
msg.ParseMode = "Markdown" // Markdown (Legacy) format
_, err := a.telegramBot.Send(msg)
if err != nil {
log.Printf("ERROR sending Telegram message: %v", err)
} else {
log.Printf("Telegram message sent successfully to chat ID %d", a.telegramChatID)
}
}
// 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(`
INSERT INTO projects (name)
VALUES ($1)
ON CONFLICT (name) DO UPDATE
SET name = EXCLUDED.name
`, 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(`
INSERT INTO nodes (project_id, entry_id, score)
SELECT p.id, $1, $2
FROM projects p
WHERE p.name = $3
`, 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 (
-- Считаем медиану на основе последних 12 записей из вьюхи
SELECT
project_id,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY total_score) AS median_score
FROM (
SELECT
project_id,
total_score,
-- Нумеруем недели от новых к старым
ROW_NUMBER() OVER (PARTITION BY project_id ORDER BY report_year DESC, report_week DESC) as rn
FROM weekly_report_mv
) sub
WHERE rn <= 12 -- Берем историю за последние 12 недель активности
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,
COALESCE(gm.median_score, 0) AS min_goal_score,
-- Логика max_score в зависимости от приоритета
CASE
WHEN p.priority = 1 THEN COALESCE(gm.median_score, 0) * 1.5
WHEN p.priority = 2 THEN COALESCE(gm.median_score, 0) * 1.3
ELSE COALESCE(gm.median_score, 0) * 1.2
END + (CASE WHEN COALESCE(gm.median_score, 0) = 0 THEN 10 ELSE 0 END) AS max_goal_score,
p.priority
FROM projects p
CROSS JOIN current_info ci
LEFT JOIN goal_metrics gm ON p.id = gm.project_id
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
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
ORDER BY
p.name
`
rows, err := a.DB.Query(selectQuery)
if err != nil {
log.Printf("Error querying weekly goals: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error querying weekly goals: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
goals := make([]WeeklyGoalSetup, 0)
for rows.Next() {
var goal WeeklyGoalSetup
var maxGoalScore sql.NullFloat64
err := rows.Scan(
&goal.ProjectName,
&goal.MinGoalScore,
&maxGoalScore,
)
if err != nil {
log.Printf("Error scanning weekly goal row: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error scanning data: %v", err), http.StatusInternalServerError)
return
}
if maxGoalScore.Valid {
goal.MaxGoalScore = maxGoalScore.Float64
} else {
goal.MaxGoalScore = 0.0
}
goals = append(goals, goal)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(goals)
}
// dailyReportTriggerHandler обрабатывает запрос на отправку ежедневного отчёта
func (a *App) dailyReportTriggerHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
log.Printf("Manual trigger: Sending daily report")
err := a.sendDailyReport()
if err != nil {
log.Printf("Error in manual daily report trigger: %v", err)
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Daily report sent successfully",
})
}
func (a *App) adminHandler(w http.ResponseWriter, r *http.Request) {
// Пробуем найти файл admin.html в разных местах
var adminPath string
// 1. Пробуем в текущей рабочей директории
if _, err := os.Stat("admin.html"); err == nil {
adminPath = "admin.html"
} else {
// 2. Пробуем в директории play-life-backend относительно текущей директории
adminPath = filepath.Join("play-life-backend", "admin.html")
if _, err := os.Stat(adminPath); err != nil {
// 3. Пробуем получить путь к исполняемому файлу и искать рядом
if execPath, err := os.Executable(); err == nil {
execDir := filepath.Dir(execPath)
adminPath = filepath.Join(execDir, "admin.html")
if _, err := os.Stat(adminPath); err != nil {
// 4. Последняя попытка - просто "admin.html"
adminPath = "admin.html"
}
} else {
adminPath = "admin.html"
}
}
}
http.ServeFile(w, r, adminPath)
}
// recreateMaterializedViewHandler пересоздает materialized view с исправленной логикой ISOYEAR
func (a *App) recreateMaterializedViewHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
log.Printf("Recreating materialized view weekly_report_mv with ISOYEAR fix")
// Удаляем старый view
dropMaterializedView := `DROP MATERIALIZED VIEW IF EXISTS weekly_report_mv`
if _, err := a.DB.Exec(dropMaterializedView); err != nil {
log.Printf("Error dropping materialized view: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error dropping materialized view: %v", err), http.StatusInternalServerError)
return
}
// Создаем новый view с ISOYEAR
createMaterializedView := `
CREATE MATERIALIZED VIEW weekly_report_mv AS
SELECT
p.id AS project_id,
agg.report_year,
agg.report_week,
COALESCE(agg.total_score, 0.0000) AS total_score
FROM
projects p
LEFT JOIN
(
SELECT
n.project_id,
EXTRACT(ISOYEAR FROM e.created_date)::INTEGER AS report_year,
EXTRACT(WEEK FROM e.created_date)::INTEGER AS report_week,
SUM(n.score) AS total_score
FROM
nodes n
JOIN
entries e ON n.entry_id = e.id
GROUP BY
1, 2, 3
) agg
ON p.id = agg.project_id
ORDER BY
p.id, agg.report_year, agg.report_week
`
if _, err := a.DB.Exec(createMaterializedView); err != nil {
log.Printf("Error creating materialized view: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating materialized view: %v", err), http.StatusInternalServerError)
return
}
// Создаем индекс
createMVIndex := `
CREATE INDEX IF NOT EXISTS idx_weekly_report_mv_project_year_week
ON weekly_report_mv(project_id, report_year, report_week)
`
if _, err := a.DB.Exec(createMVIndex); err != nil {
log.Printf("Warning: Failed to create materialized view index: %v", err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Materialized view recreated successfully with ISOYEAR fix",
})
}
func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "OPTIONS" {
setCORSHeaders(w)
w.WriteHeader(http.StatusOK)
return
}
setCORSHeaders(w)
query := `
SELECT
id AS project_id,
name AS project_name,
priority
FROM
projects
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),
})
}
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
}
// Проверяем, что есть message
if update.Message.Text == "" {
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
}
fullText := update.Message.Text
entities := update.Message.Entities
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
ON p.id = COALESCE(wr.project_id, wg.project_id)
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)
}