diff --git a/VERSION b/VERSION index d7638f3..58fe352 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.22.0 +4.23.0 diff --git a/docker-compose.yml b/docker-compose.yml index 4c40684..e6fcf86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: POSTGRES_DB: ${DB_NAME:-playeng} ports: - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-playeng}"] interval: 10s @@ -59,6 +61,10 @@ services: env_file: - .env +volumes: + postgres_data: + name: play-life_postgres_data + networks: default: name: play-life-network diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 49bb2e4..2bd8ffb 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -270,6 +270,49 @@ type TelegramUpdate struct { EditedMessage *TelegramMessage `json:"edited_message,omitempty"` } +// Tracking structures +type TrackingUserStats struct { + UserID int `json:"user_id"` + UserName string `json:"user_name"` + IsCurrentUser bool `json:"is_current_user"` + Total *float64 `json:"total,omitempty"` + Projects []TrackingProjectStats `json:"projects"` +} + +type TrackingProjectStats struct { + ProjectName string `json:"project_name"` + CalculatedScore float64 `json:"calculated_score"` // процент выполнения + Priority *int `json:"priority,omitempty"` +} + +type TrackingStatsResponse struct { + WeekNumber int `json:"week_number"` + Year int `json:"year"` + Users []TrackingUserStats `json:"users"` +} + +type TrackingAccessResponse struct { + Trackers []TrackingUser `json:"trackers"` // кто меня отслеживает + Tracked []TrackingUser `json:"tracked"` // кого я отслеживаю +} + +type TrackingUser struct { + ID int `json:"id"` + RelationID int `json:"relation_id"` // id записи в user_tracking для удаления + Name string `json:"name"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +type TrackingInviteInfo struct { + UserID int `json:"user_id"` + UserName string `json:"user_name"` +} + +type TrackingInviteResponse struct { + InviteURL string `json:"invite_url"` +} + // Task structures type Task struct { ID int `json:"id"` @@ -432,7 +475,7 @@ type UnlockConditionDisplay struct { TaskID *int `json:"task_id,omitempty"` // ID задачи (для task_completion) TaskName *string `json:"task_name,omitempty"` TaskNextShowAt *string `json:"task_next_show_at,omitempty"` // Дата следующего показа задачи (для task_completion) - ProjectID *int `json:"project_id,omitempty"` // ID проекта (для project_points) + ProjectID *int `json:"project_id,omitempty"` // ID проекта (для project_points) ProjectName *string `json:"project_name,omitempty"` RequiredPoints *float64 `json:"required_points,omitempty"` StartDate *string `json:"start_date,omitempty"` // Дата начала подсчёта (YYYY-MM-DD), NULL = за всё время @@ -2698,28 +2741,39 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { } // Параметры бонуса в зависимости от priority - var extraBonusLimit float64 = 40 + var extraBonusLimit float64 = 20 if priorityVal == 1 { - extraBonusLimit = 100 + extraBonusLimit = 50 } else if priorityVal == 2 { - extraBonusLimit = 70 + extraBonusLimit = 35 } - // Расчет базового прогресса - var baseProgress float64 - if minGoalScoreVal > 0 { - baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 + // Расчет calculated_score по логике фронтенда + // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета + var resultScore float64 + if minGoalScoreVal <= 0 { + // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) + resultScore = 0 + } else if totalScore < minGoalScoreVal { + // До достижения minGoal растем линейно от 0 до 100% + resultScore = (totalScore / minGoalScoreVal) * 100.0 + } else { + // Достигнут minGoal - базовый прогресс = 100% + baseProgress := 100.0 + + // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс + if maxGoalScoreVal > minGoalScoreVal { + extraRange := maxGoalScoreVal - minGoalScoreVal + excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal + extraRatio := min(1.0, max(0.0, excess/extraRange)) + extraProgress := extraRatio * extraBonusLimit + resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) + } else { + // Если maxGoal не задан или некорректен, просто 100% + resultScore = baseProgress + } } - // Расчет экстра прогресса - var extraProgress float64 - denominator := maxGoalScoreVal - minGoalScoreVal - if denominator > 0 && totalScore > minGoalScoreVal { - excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal - extraProgress = (excess / denominator) * extraBonusLimit - } - - resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета @@ -2738,7 +2792,7 @@ func (a *App) getWeeklyStatsHandler(w http.ResponseWriter, r *http.Request) { groupsProgress := calculateGroupsProgress(groups) // Вычисляем общий процент выполнения - total := calculateOverallProgress(groupsProgress) + total := calculateOverallProgress(groupsProgress, groups) response := WeeklyStatsResponse{ Total: total, @@ -3232,28 +3286,39 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { } // Параметры бонуса в зависимости от priority - var extraBonusLimit float64 = 40 + var extraBonusLimit float64 = 20 if priorityVal == 1 { - extraBonusLimit = 100 + extraBonusLimit = 50 } else if priorityVal == 2 { - extraBonusLimit = 70 + extraBonusLimit = 35 } - // Расчет базового прогресса - var baseProgress float64 - if minGoalScoreVal > 0 { - baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 + // Расчет calculated_score по логике фронтенда + // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета + var resultScore float64 + if minGoalScoreVal <= 0 { + // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) + resultScore = 0 + } else if totalScore < minGoalScoreVal { + // До достижения minGoal растем линейно от 0 до 100% + resultScore = (totalScore / minGoalScoreVal) * 100.0 + } else { + // Достигнут minGoal - базовый прогресс = 100% + baseProgress := 100.0 + + // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс + if maxGoalScoreVal > minGoalScoreVal { + extraRange := maxGoalScoreVal - minGoalScoreVal + excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal + extraRatio := min(1.0, max(0.0, excess/extraRange)) + extraProgress := extraRatio * extraBonusLimit + resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) + } else { + // Если maxGoal не задан или некорректен, просто 100% + resultScore = baseProgress + } } - // Расчет экстра прогресса - var extraProgress float64 - denominator := maxGoalScoreVal - minGoalScoreVal - if denominator > 0 && totalScore > minGoalScoreVal { - excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal - extraProgress = (excess / denominator) * extraBonusLimit - } - - resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) // Группировка для итогового расчета @@ -3272,7 +3337,7 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { groupsProgress := calculateGroupsProgress(groups) // Вычисляем общий процент выполнения - total := calculateOverallProgress(groupsProgress) + total := calculateOverallProgress(groupsProgress, groups) response := WeeklyStatsResponse{ Total: total, @@ -3392,28 +3457,39 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error } // Параметры бонуса в зависимости от priority - var extraBonusLimit float64 = 40 + var extraBonusLimit float64 = 20 if priorityVal == 1 { - extraBonusLimit = 100 + extraBonusLimit = 50 } else if priorityVal == 2 { - extraBonusLimit = 70 + extraBonusLimit = 35 } - // Расчет базового прогресса - var baseProgress float64 - if minGoalScoreVal > 0 { - baseProgress = (min(totalScore, minGoalScoreVal) / minGoalScoreVal) * 100.0 + // Расчет calculated_score по логике фронтенда + // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета + var resultScore float64 + if minGoalScoreVal <= 0 { + // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) + resultScore = 0 + } else if totalScore < minGoalScoreVal { + // До достижения minGoal растем линейно от 0 до 100% + resultScore = (totalScore / minGoalScoreVal) * 100.0 + } else { + // Достигнут minGoal - базовый прогресс = 100% + baseProgress := 100.0 + + // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс + if maxGoalScoreVal > minGoalScoreVal { + extraRange := maxGoalScoreVal - minGoalScoreVal + excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal + extraRatio := min(1.0, max(0.0, excess/extraRange)) + extraProgress := extraRatio * extraBonusLimit + resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) + } else { + // Если maxGoal не задан или некорректен, просто 100% + resultScore = baseProgress + } } - // Расчет экстра прогресса - var extraProgress float64 - denominator := maxGoalScoreVal - minGoalScoreVal - if denominator > 0 && totalScore > minGoalScoreVal { - excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal - extraProgress = (excess / denominator) * extraBonusLimit - } - - resultScore := baseProgress + extraProgress project.CalculatedScore = roundToTwoDecimals(resultScore) projects = append(projects, project) @@ -3431,7 +3507,7 @@ func (a *App) getWeeklyStatsDataForUser(userID int) (*WeeklyStatsResponse, error groupsProgress := calculateGroupsProgress(groups) // Вычисляем общий процент выполнения - total := calculateOverallProgress(groupsProgress) + total := calculateOverallProgress(groupsProgress, groups) response := WeeklyStatsResponse{ Total: total, @@ -3977,6 +4053,15 @@ func main() { protected.HandleFunc("/api/wishlist/invite/{token}", app.getBoardInviteInfoHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/invite/{token}/join", app.joinBoardHandler).Methods("POST", "OPTIONS") + // Tracking + protected.HandleFunc("/api/tracking/stats", app.getTrackingStatsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/tracking/invite", app.createTrackingInviteHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/tracking/invite/{token}", app.getTrackingInviteInfoHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/tracking/invite/{token}/accept", app.acceptTrackingInviteHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/tracking/access", app.getTrackingAccessHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/tracking/trackers/{id}", app.deleteTrackingTrackerHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/tracking/tracked/{id}", app.deleteTrackingTrackedHandler).Methods("DELETE", "OPTIONS") + // Wishlist items (после boards, чтобы {id} не перехватывал "boards") protected.HandleFunc("/api/wishlist/{id}", app.getWishlistItemHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/{id}", app.updateWishlistHandler).Methods("PUT", "OPTIONS") @@ -4091,6 +4176,7 @@ func roundToFourDecimals(val float64) float64 { // groups - карта приоритетов к спискам calculatedScore проектов // Возвращает структуру GroupsProgress с процентами для каждой группы // Если какая-то группа отсутствует, она считается как 100% +// min_goal = 100%, max_goal = 150%/135%/120% в зависимости от приоритета func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { // Всего есть 3 группы: приоритет 1, приоритет 2, приоритет 0 // Вычисляем среднее для каждой группы, если она есть @@ -4109,34 +4195,22 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { // Если группы нет, считаем как 100% avg = 100.0 } else { - // Вычисляем среднее для группы - // Для всех приоритетов: если calculated_score > 100%, избыточная часть делится на 2 - // Функция для корректировки score: если > 100%, то 100 + (score - 100) / 2 - adjustScore := func(score float64) float64 { - if score > 100.0 { - return 100.0 + (score-100.0)/2.0 - } - return score - } - - // Для приоритета 1 и 2 - обычное среднее с корректировкой + // Для приоритета 1 и 2 - обычное среднее if priorityVal == 1 || priorityVal == 2 { sum := 0.0 for _, score := range scores { - sum += adjustScore(score) + sum += score } avg = sum / float64(len(scores)) } else { - // Для проектов без приоритета (priorityVal == 0) - специальная формула с корректировкой + // Для проектов без приоритета (priorityVal == 0) - специальная формула projectCount := float64(len(scores)) multiplier := 100.0 / (projectCount * 0.8) sum := 0.0 for _, score := range scores { - // Применяем корректировку перед использованием в формуле - adjustedScore := adjustScore(score) // score уже в процентах (например, 80.0), переводим в долю (0.8) - scoreAsDecimal := adjustedScore / 100.0 + scoreAsDecimal := score / 100.0 sum += scoreAsDecimal * multiplier } @@ -4161,33 +4235,37 @@ func calculateGroupsProgress(groups map[int][]float64) GroupsProgress { // calculateOverallProgress вычисляет общий процент выполнения на основе процентов групп // groupsProgress - структура с процентами для каждой группы приоритетов +// groups - карта приоритетов к спискам calculatedScore проектов (используется для точного расчета) // Возвращает указатель на float64 с общим процентом выполнения -// Всегда вычисляет среднее всех трех групп (даже если какая-то группа отсутствует, она считается как 100%) -func calculateOverallProgress(groupsProgress GroupsProgress) *float64 { - // Находим среднее между всеми тремя группами - // Если какая-то группа отсутствует (nil), считаем её как 100% - - var group1Val, group2Val, group0Val float64 +// Вычисляет среднее между группами (min_goal = 100%, max_goal = 150%/135%/120%) +func calculateOverallProgress(groupsProgress GroupsProgress, groups map[int][]float64) *float64 { + // Собираем проценты по группам + var groupScores []float64 + // Добавляем проценты только тех групп, которые существуют (имеют проекты) if groupsProgress.Group1 != nil { - group1Val = *groupsProgress.Group1 - } else { - group1Val = 100.0 + groupScores = append(groupScores, *groupsProgress.Group1) } - if groupsProgress.Group2 != nil { - group2Val = *groupsProgress.Group2 - } else { - group2Val = 100.0 + groupScores = append(groupScores, *groupsProgress.Group2) } - if groupsProgress.Group0 != nil { - group0Val = *groupsProgress.Group0 - } else { - group0Val = 100.0 + groupScores = append(groupScores, *groupsProgress.Group0) } - overallProgress := (group1Val + group2Val + group0Val) / 3.0 // Всегда делим на 3, так как групп всегда 3 + // Если нет групп с проектами, возвращаем 0 + if len(groupScores) == 0 { + zero := 0.0 + return &zero + } + + // Вычисляем среднее между группами + var sum float64 + for _, score := range groupScores { + sum += score + } + + overallProgress := sum / float64(len(groupScores)) overallProgressRounded := roundToFourDecimals(overallProgress) total := &overallProgressRounded @@ -14623,6 +14701,650 @@ func (a *App) proxyImageHandler(w http.ResponseWriter, r *http.Request) { w.Write(bodyBytes) } +// ============================================ +// Tracking handlers +// ============================================ + +// getWeeklyStatsDataForUserAndWeek получает данные о проектах для конкретного пользователя и недели +func (a *App) getWeeklyStatsDataForUserAndWeek(userID int, year int, week int) (*WeeklyStatsResponse, error) { + // Определяем, является ли это текущей неделей + now := time.Now() + currentYear, currentWeek := now.ISOWeek() + isCurrentWeek := year == currentYear && week == currentWeek + + var currentWeekScores map[int]float64 + var err error + + if isCurrentWeek { + // Для текущей недели используем realtime данные + currentWeekScores, err = a.getCurrentWeekScores(userID) + if err != nil { + log.Printf("Error getting current week scores: %v", err) + return nil, fmt.Errorf("error getting current week scores: %w", err) + } + } else { + // Для исторических недель используем пустой map (данные из MV) + currentWeekScores = make(map[int]float64) + } + + query := ` + SELECT + p.id AS project_id, + p.name AS project_name, + COALESCE(wr.total_score, 0.0000) AS total_score, + wg.min_goal_score, + wg.max_goal_score, + wg.priority AS priority, + p.color + FROM + projects p + LEFT JOIN + weekly_goals wg ON wg.project_id = p.id + AND wg.goal_year = $2 + AND wg.goal_week = $3 + LEFT JOIN + weekly_report_mv wr + ON p.id = wr.project_id + AND $2 = wr.report_year + AND $3 = wr.report_week + WHERE + p.deleted = FALSE AND p.user_id = $1 + ORDER BY + total_score DESC + ` + + rows, err := a.DB.Query(query, userID, year, week) + if err != nil { + return nil, fmt.Errorf("error querying weekly stats: %w", err) + } + defer rows.Close() + + projects := make([]WeeklyProjectStats, 0) + groups := make(map[int][]float64) + + for rows.Next() { + var project WeeklyProjectStats + var projectID int + var minGoalScore sql.NullFloat64 + var maxGoalScore sql.NullFloat64 + var priority sql.NullInt64 + + err := rows.Scan( + &projectID, + &project.ProjectName, + &project.TotalScore, + &minGoalScore, + &maxGoalScore, + &priority, + &project.Color, + ) + if err != nil { + return nil, fmt.Errorf("error scanning weekly stats row: %w", err) + } + + // Объединяем данные: если это текущая неделя и есть данные, используем их вместо MV + if isCurrentWeek { + if currentWeekScore, exists := currentWeekScores[projectID]; exists { + project.TotalScore = currentWeekScore + } + } + + if minGoalScore.Valid { + project.MinGoalScore = minGoalScore.Float64 + } else { + project.MinGoalScore = 0 + } + + if maxGoalScore.Valid { + maxGoalVal := maxGoalScore.Float64 + project.MaxGoalScore = &maxGoalVal + } + + var priorityVal int + if priority.Valid { + priorityVal = int(priority.Int64) + project.Priority = &priorityVal + } + + // Расчет calculated_score + totalScore := project.TotalScore + minGoalScoreVal := project.MinGoalScore + var maxGoalScoreVal float64 + if project.MaxGoalScore != nil { + maxGoalScoreVal = *project.MaxGoalScore + } + + // Параметры бонуса в зависимости от priority + var extraBonusLimit float64 = 20 + if priorityVal == 1 { + extraBonusLimit = 50 + } else if priorityVal == 2 { + extraBonusLimit = 35 + } + + // Расчет calculated_score по логике фронтенда + // min_goal -> 100%, max_goal -> 150%/135%/120% в зависимости от приоритета + var resultScore float64 + if minGoalScoreVal <= 0 { + // Если нет minGoal, возвращаем 0 (или можно относительно maxGoal, но обычно 0) + resultScore = 0 + } else if totalScore < minGoalScoreVal { + // До достижения minGoal растем линейно от 0 до 100% + resultScore = (totalScore / minGoalScoreVal) * 100.0 + } else { + // Достигнут minGoal - базовый прогресс = 100% + baseProgress := 100.0 + + // Если maxGoal задан корректно и больше minGoal, добавляем экстра прогресс + if maxGoalScoreVal > minGoalScoreVal { + extraRange := maxGoalScoreVal - minGoalScoreVal + excess := min(totalScore, maxGoalScoreVal) - minGoalScoreVal + extraRatio := min(1.0, max(0.0, excess/extraRange)) + extraProgress := extraRatio * extraBonusLimit + resultScore = min(100.0+extraBonusLimit, baseProgress+extraProgress) + } else { + // Если maxGoal не задан или некорректен, просто 100% + resultScore = baseProgress + } + } + + project.CalculatedScore = roundToTwoDecimals(resultScore) + projects = append(projects, project) + + // Группировка для итогового расчета + if minGoalScoreVal > 0 { + if _, exists := groups[priorityVal]; !exists { + groups[priorityVal] = make([]float64, 0) + } + groups[priorityVal] = append(groups[priorityVal], project.CalculatedScore) + } + } + + // Вычисляем проценты для каждой группы + groupsProgress := calculateGroupsProgress(groups) + + // Вычисляем общий процент выполнения + total := calculateOverallProgress(groupsProgress, groups) + + response := WeeklyStatsResponse{ + Total: total, + GroupProgress1: groupsProgress.Group1, + GroupProgress2: groupsProgress.Group2, + GroupProgress0: groupsProgress.Group0, + Projects: projects, + } + + return &response, nil +} + +// getISOWeek вычисляет номер недели ISO для даты +func getISOWeek(t time.Time) int { + _, week := t.ISOWeek() + return week +} + +// getTrackingStatsHandler возвращает статистику недели для текущего пользователя и отслеживаемых +func (a *App) getTrackingStatsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Получаем year и week из query params + yearStr := r.URL.Query().Get("year") + weekStr := r.URL.Query().Get("week") + + var year, week int + now := time.Now() + + if yearStr == "" || weekStr == "" { + // Если не указаны - текущая неделя + year, week = now.ISOWeek() + } else { + var err error + year, err = strconv.Atoi(yearStr) + if err != nil { + sendErrorWithCORS(w, "Invalid year parameter", http.StatusBadRequest) + return + } + week, err = strconv.Atoi(weekStr) + if err != nil { + sendErrorWithCORS(w, "Invalid week parameter", http.StatusBadRequest) + return + } + } + + // Получаем список отслеживаемых пользователей + rows, err := a.DB.Query(` + SELECT tracked_id + FROM user_tracking + WHERE tracker_id = $1 + `, userID) + if err != nil { + log.Printf("Error getting tracked users: %v", err) + sendErrorWithCORS(w, "Error getting tracked users", http.StatusInternalServerError) + return + } + defer rows.Close() + + trackedUserIDs := []int{userID} // Начинаем с текущего пользователя + for rows.Next() { + var trackedID int + if err := rows.Scan(&trackedID); err != nil { + log.Printf("Error scanning tracked user: %v", err) + continue + } + trackedUserIDs = append(trackedUserIDs, trackedID) + } + + // Получаем данные для каждого пользователя + users := make([]TrackingUserStats, 0) + for _, uid := range trackedUserIDs { + stats, err := a.getWeeklyStatsDataForUserAndWeek(uid, year, week) + if err != nil { + log.Printf("Error getting stats for user %d: %v", uid, err) + continue + } + + // Получаем имя пользователя + var userName string + err = a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, uid).Scan(&userName) + if err != nil { + log.Printf("Error getting user name: %v", err) + userName = "Unknown" + } + + // Преобразуем проекты в TrackingProjectStats + projects := make([]TrackingProjectStats, 0) + for _, p := range stats.Projects { + projects = append(projects, TrackingProjectStats{ + ProjectName: p.ProjectName, + CalculatedScore: p.CalculatedScore, + Priority: p.Priority, + }) + } + + // Сортируем проекты по priority (1, 2, остальные) + sort.Slice(projects, func(i, j int) bool { + pi, pj := 99, 99 + if projects[i].Priority != nil { + pi = *projects[i].Priority + } + if projects[j].Priority != nil { + pj = *projects[j].Priority + } + return pi < pj + }) + + users = append(users, TrackingUserStats{ + UserID: uid, + UserName: userName, + IsCurrentUser: uid == userID, + Total: stats.Total, + Projects: projects, + }) + } + + // Сортируем: текущий пользователь всегда первый + sortedUsers := make([]TrackingUserStats, 0) + for _, u := range users { + if u.IsCurrentUser { + sortedUsers = append([]TrackingUserStats{u}, sortedUsers...) + } else { + sortedUsers = append(sortedUsers, u) + } + } + + response := TrackingStatsResponse{ + WeekNumber: week, + Year: year, + Users: sortedUsers, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// createTrackingInviteHandler создает токен приглашения +func (a *App) createTrackingInviteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Генерируем токен + token := generateInviteToken() + + // Сохраняем в базу с истечением через 1 час + _, err := a.DB.Exec(` + INSERT INTO tracking_invite_tokens (user_id, token, expires_at) + VALUES ($1, $2, NOW() + INTERVAL '1 hour') + `, userID, token) + if err != nil { + log.Printf("Error creating tracking invite token: %v", err) + sendErrorWithCORS(w, "Error creating invite token", http.StatusInternalServerError) + return + } + + // Формируем URL + baseURL := getEnv("WEBHOOK_BASE_URL", "") + inviteURL := baseURL + "/tracking/invite/" + token + + response := TrackingInviteResponse{ + InviteURL: inviteURL, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// getTrackingInviteInfoHandler возвращает информацию о приглашении +func (a *App) getTrackingInviteInfoHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + vars := mux.Vars(r) + token := vars["token"] + + var userID int + var expiresAt time.Time + err := a.DB.QueryRow(` + SELECT user_id, expires_at + FROM tracking_invite_tokens + WHERE token = $1 + `, token).Scan(&userID, &expiresAt) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting invite token: %v", err) + sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) + return + } + + // Проверяем срок действия + if time.Now().After(expiresAt) { + sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound) + return + } + + // Получаем имя пользователя + var userName string + err = a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, userID).Scan(&userName) + if err != nil { + log.Printf("Error getting user name: %v", err) + sendErrorWithCORS(w, "Error getting user info", http.StatusInternalServerError) + return + } + + response := TrackingInviteInfo{ + UserID: userID, + UserName: userName, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// acceptTrackingInviteHandler принимает приглашение на отслеживание +func (a *App) acceptTrackingInviteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + currentUserID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + token := vars["token"] + + // Получаем информацию о токене + var trackedUserID int + var expiresAt time.Time + err := a.DB.QueryRow(` + SELECT user_id, expires_at + FROM tracking_invite_tokens + WHERE token = $1 AND expires_at > NOW() + `, token).Scan(&trackedUserID, &expiresAt) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Ссылка недействительна или устарела", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting invite token: %v", err) + sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) + return + } + + // Проверяем, что пользователь не пытается отслеживать себя + if trackedUserID == currentUserID { + sendErrorWithCORS(w, "Нельзя отслеживать себя", http.StatusBadRequest) + return + } + + // Создаем запись отслеживания + _, err = a.DB.Exec(` + INSERT INTO user_tracking (tracker_id, tracked_id) + VALUES ($1, $2) + ON CONFLICT (tracker_id, tracked_id) DO NOTHING + `, currentUserID, trackedUserID) + if err != nil { + log.Printf("Error creating tracking relation: %v", err) + sendErrorWithCORS(w, "Error accepting invite", http.StatusInternalServerError) + return + } + + // Удаляем использованный токен (одноразовый) + _, err = a.DB.Exec(`DELETE FROM tracking_invite_tokens WHERE token = $1`, token) + if err != nil { + log.Printf("Error deleting used token: %v", err) + // Не критично, продолжаем + } + + // Получаем имя пользователя для ответа + var userName string + a.DB.QueryRow(`SELECT COALESCE(name, email) FROM users WHERE id = $1`, trackedUserID).Scan(&userName) + + response := map[string]interface{}{ + "success": true, + "tracked_user_name": userName, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// getTrackingAccessHandler возвращает списки трекеров и отслеживаемых +func (a *App) getTrackingAccessHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Trackers (кто меня отслеживает) + trackers := make([]TrackingUser, 0) + rows, err := a.DB.Query(` + SELECT ut.id as relation_id, u.id, COALESCE(u.name, u.email) as name, u.email, ut.created_at + FROM user_tracking ut + JOIN users u ON ut.tracker_id = u.id + WHERE ut.tracked_id = $1 + ORDER BY ut.created_at DESC + `, userID) + if err != nil { + log.Printf("Error getting trackers: %v", err) + sendErrorWithCORS(w, "Error getting trackers", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var t TrackingUser + if err := rows.Scan(&t.RelationID, &t.ID, &t.Name, &t.Email, &t.CreatedAt); err != nil { + log.Printf("Error scanning tracker: %v", err) + continue + } + trackers = append(trackers, t) + } + + // Tracked (кого я отслеживаю) + tracked := make([]TrackingUser, 0) + rows, err = a.DB.Query(` + SELECT ut.id as relation_id, u.id, COALESCE(u.name, u.email) as name, u.email, ut.created_at + FROM user_tracking ut + JOIN users u ON ut.tracked_id = u.id + WHERE ut.tracker_id = $1 + ORDER BY ut.created_at DESC + `, userID) + if err != nil { + log.Printf("Error getting tracked: %v", err) + sendErrorWithCORS(w, "Error getting tracked", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var t TrackingUser + if err := rows.Scan(&t.RelationID, &t.ID, &t.Name, &t.Email, &t.CreatedAt); err != nil { + log.Printf("Error scanning tracked: %v", err) + continue + } + tracked = append(tracked, t) + } + + response := TrackingAccessResponse{ + Trackers: trackers, + Tracked: tracked, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// deleteTrackingTrackerHandler удаляет того, кто меня отслеживает +func (a *App) deleteTrackingTrackerHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + relationIDStr := vars["id"] + relationID, err := strconv.Atoi(relationIDStr) + if err != nil { + sendErrorWithCORS(w, "Invalid relation ID", http.StatusBadRequest) + return + } + + // Удаляем только если это действительно тот, кто отслеживает меня + result, err := a.DB.Exec(` + DELETE FROM user_tracking + WHERE id = $1 AND tracked_id = $2 + `, relationID, userID) + if err != nil { + log.Printf("Error deleting tracker relation: %v", err) + sendErrorWithCORS(w, "Error deleting relation", http.StatusInternalServerError) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + sendErrorWithCORS(w, "Relation not found or access denied", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) +} + +// deleteTrackingTrackedHandler прекращает отслеживать пользователя +func (a *App) deleteTrackingTrackedHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + vars := mux.Vars(r) + relationIDStr := vars["id"] + relationID, err := strconv.Atoi(relationIDStr) + if err != nil { + sendErrorWithCORS(w, "Invalid relation ID", http.StatusBadRequest) + return + } + + // Удаляем только если это действительно тот, кого я отслеживаю + result, err := a.DB.Exec(` + DELETE FROM user_tracking + WHERE id = $1 AND tracker_id = $2 + `, relationID, userID) + if err != nil { + log.Printf("Error deleting tracked relation: %v", err) + sendErrorWithCORS(w, "Error deleting relation", http.StatusInternalServerError) + return + } + + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + sendErrorWithCORS(w, "Relation not found or access denied", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) +} + // decodeHTMLEntities декодирует базовые HTML entities func decodeHTMLEntities(s string) string { replacements := map[string]string{ diff --git a/play-life-backend/migrations/000013_add_user_tracking.down.sql b/play-life-backend/migrations/000013_add_user_tracking.down.sql new file mode 100644 index 0000000..06ebad0 --- /dev/null +++ b/play-life-backend/migrations/000013_add_user_tracking.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS tracking_invite_tokens; +DROP TABLE IF EXISTS user_tracking; diff --git a/play-life-backend/migrations/000013_add_user_tracking.up.sql b/play-life-backend/migrations/000013_add_user_tracking.up.sql new file mode 100644 index 0000000..f31db29 --- /dev/null +++ b/play-life-backend/migrations/000013_add_user_tracking.up.sql @@ -0,0 +1,24 @@ +-- Таблица отслеживания между пользователями +CREATE TABLE user_tracking ( + id SERIAL PRIMARY KEY, + tracker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tracked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_tracking_pair UNIQUE (tracker_id, tracked_id), + CONSTRAINT no_self_tracking CHECK (tracker_id != tracked_id) +); + +CREATE INDEX idx_user_tracking_tracker ON user_tracking(tracker_id); +CREATE INDEX idx_user_tracking_tracked ON user_tracking(tracked_id); + +-- Таблица токенов приглашений (живут 1 час) +CREATE TABLE tracking_invite_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_tracking_invite_tokens_token ON tracking_invite_tokens(token); +CREATE INDEX idx_tracking_invite_tokens_user ON tracking_invite_tokens(user_id); diff --git a/play-life-web/package.json b/play-life-web/package.json index de5e057..69a75c9 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.22.0", + "version": "4.23.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index d9bdbac..0a013e1 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -16,6 +16,9 @@ import BoardForm from './components/BoardForm' import BoardJoinPreview from './components/BoardJoinPreview' import TodoistIntegration from './components/TodoistIntegration' import TelegramIntegration from './components/TelegramIntegration' +import Tracking from './components/Tracking' +import TrackingAccess from './components/TrackingAccess' +import TrackingInviteAccept from './components/TrackingInviteAccept' import { AuthProvider, useAuth } from './components/auth/AuthContext' import AuthScreen from './components/auth/AuthScreen' import PWAUpdatePrompt from './components/PWAUpdatePrompt' @@ -26,7 +29,7 @@ const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b' // Определяем основные табы (без крестика) и глубокие табы (с крестиком) const mainTabs = ['current', 'tasks', 'wishlist', 'profile'] -const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities'] +const deepTabs = ['add-words', 'test', 'task-form', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'words', 'dictionaries', 'todoist-integration', 'telegram-integration', 'full', 'priorities', 'tracking', 'tracking-access', 'tracking-invite'] function AppContent() { const { authFetch, isAuthenticated, loading: authLoading } = useAuth() @@ -64,6 +67,9 @@ function AppContent() { profile: false, 'todoist-integration': false, 'telegram-integration': false, + tracking: false, + 'tracking-access': false, + 'tracking-invite': false, }) // Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок) @@ -85,6 +91,9 @@ function AppContent() { profile: false, 'todoist-integration': false, 'telegram-integration': false, + tracking: false, + 'tracking-access': false, + 'tracking-invite': false, }) // Параметры для навигации между вкладками @@ -155,10 +164,23 @@ function AppContent() { } } + // Проверяем путь /tracking/invite/:token + if (path.startsWith('/tracking/invite/')) { + const token = path.replace('/tracking/invite/', '') + if (token) { + setActiveTab('tracking-invite') + setLoadedTabs(prev => ({ ...prev, 'tracking-invite': true })) + setTabParams({ inviteToken: token }) + setIsInitialized(true) + window.history.replaceState({}, '', '/?tab=tracking-invite&inviteToken=' + token) + return + } + } + // Проверяем URL только для глубоких табов const urlParams = new URLSearchParams(window.location.search) const tabFromUrl = urlParams.get('tab') - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'board-form', 'board-join', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite'] if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) { // Если в URL есть глубокий таб, восстанавливаем его @@ -469,6 +491,9 @@ function AppContent() { profile: false, 'todoist-integration': false, 'telegram-integration': false, + tracking: false, + 'tracking-access': false, + 'tracking-invite': false, }) // Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback) @@ -618,7 +643,7 @@ function AppContent() { return } - const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration'] + const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'dictionaries', 'test', 'tasks', 'task-form', 'wishlist', 'wishlist-form', 'wishlist-detail', 'profile', 'todoist-integration', 'telegram-integration', 'tracking', 'tracking-access', 'tracking-invite'] // Проверяем state текущей записи истории (куда мы вернулись) if (event.state && event.state.tab) { @@ -915,7 +940,7 @@ function AppContent() { }, [activeTab]) // Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов) - const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' + const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'task-form' || activeTab === 'wishlist-form' || activeTab === 'wishlist-detail' || activeTab === 'todoist-integration' || activeTab === 'telegram-integration' || activeTab === 'full' || activeTab === 'priorities' || activeTab === 'words' || activeTab === 'dictionaries' || activeTab === 'tracking' || activeTab === 'tracking-access' || activeTab === 'tracking-invite' // Функция для получения классов скролл-контейнера для каждого таба // Каждый таб имеет свой изолированный скролл-контейнер для автоматического сохранения позиции скролла @@ -1173,6 +1198,33 @@ function AppContent() { )} + + {loadedTabs.tracking && ( +
Создайте одноразовую ссылку (действует 1 час)
+ +Пока никто не отслеживает вашу статистику
+ ) : ( + trackers.map(t => ( +Вы пока никого не отслеживаете
+ ) : ( + tracked.map(t => ( +Загрузка...
+{error}
+ +Вы сможете видеть статистику пользователя:
+{inviteInfo?.user_name}
+{error}
} + + + + +