From e955494dc8b4aa96e2a6de9ba746ed6ac606dfde Mon Sep 17 00:00:00 2001 From: poignatov Date: Fri, 30 Jan 2026 19:53:13 +0300 Subject: [PATCH] =?UTF-8?q?4.5.0:=20=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=D0=BC=D0=B8=20=D0=B6=D0=B5?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- play-life-backend/main.go | 239 ++++++++++++++---- ...0006_fix_wishlist_id_unique_index.down.sql | 13 + ...000006_fix_wishlist_id_unique_index.up.sql | 16 ++ play-life-web/package.json | 2 +- play-life-web/src/components/Wishlist.css | 27 ++ .../src/components/WishlistDetail.css | 21 ++ .../src/components/WishlistDetail.jsx | 44 ++-- 8 files changed, 302 insertions(+), 62 deletions(-) create mode 100644 play-life-backend/migrations/000006_fix_wishlist_id_unique_index.down.sql create mode 100644 play-life-backend/migrations/000006_fix_wishlist_id_unique_index.up.sql diff --git a/VERSION b/VERSION index cca25a9..a84947d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.4.1 +4.5.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 9915dad..0ebbf17 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -362,6 +362,7 @@ type WishlistItem struct { MoreLockedConditions int `json:"more_locked_conditions,omitempty"` UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` LinkedTask *LinkedTask `json:"linked_task,omitempty"` + TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания } type UnlockConditionDisplay struct { @@ -7170,12 +7171,14 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { return } - // Проверяем, что нет другой задачи с таким wishlist_id + // Проверяем, что нет другой активной (не удаленной и не выполненной) задачи с таким wishlist_id для этого пользователя + // Если задача была выполнена (completed > 0) или удалена, можно создать новую var existingTaskID int + var existingTaskCompleted int err = a.DB.QueryRow(` - SELECT id FROM tasks - WHERE wishlist_id = $1 AND deleted = FALSE - `, *req.WishlistID).Scan(&existingTaskID) + SELECT id, completed FROM tasks + WHERE wishlist_id = $1 AND user_id = $2 AND deleted = FALSE + `, *req.WishlistID, userID).Scan(&existingTaskID, &existingTaskCompleted) if err != sql.ErrNoRows { if err != nil { @@ -7183,8 +7186,24 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { sendErrorWithCORS(w, fmt.Sprintf("Error checking existing task: %v", err), http.StatusInternalServerError) return } - sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest) - return + // Если задача была выполнена (completed > 0), можно создать новую + if existingTaskCompleted > 0 { + log.Printf("Existing task %d for wishlist %d was completed (%d times), marking as deleted and allowing new task creation", existingTaskID, *req.WishlistID, existingTaskCompleted) + // Помечаем старую выполненную задачу как удаленную, чтобы избежать конфликта с уникальным индексом + _, err = a.DB.Exec(` + UPDATE tasks + SET deleted = TRUE + WHERE id = $1 + `, existingTaskID) + if err != nil { + log.Printf("Error marking existing completed task as deleted: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error marking existing task as deleted: %v", err), http.StatusInternalServerError) + return + } + } else { + sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest) + return + } } // Если название задачи не указано или пустое, используем название желания @@ -7340,6 +7359,11 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { if err != nil { log.Printf("Error creating task: %v", err) + // Проверяем, не является ли это ошибкой уникального индекса + if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") { + sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest) + return + } sendErrorWithCORS(w, fmt.Sprintf("Error creating task: %v", err), http.StatusInternalServerError) return } @@ -8697,7 +8721,8 @@ func (a *App) executeTask(taskID int, userID int, req CompleteTaskRequest) error } else { log.Printf("Wishlist item %d completed automatically after task %d completion", wishlistID.Int64, taskID) // Обрабатываем политику награждения для всех задач, связанных с этим желанием - a.processWishlistRewardPolicy(int(wishlistID.Int64), userID) + // Исключаем задачу, которая была закрыта (taskID), чтобы не обрабатывать её повторно + a.processWishlistRewardPolicy(int(wishlistID.Int64), taskID) } } @@ -9513,16 +9538,16 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) } } - // Загружаем связанную задачу, если есть + // Загружаем связанную задачу текущего пользователя, если есть var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64 var linkedTaskName sql.NullString var linkedTaskNextShowAt sql.NullTime linkedTaskErr := a.DB.QueryRow(` SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id FROM tasks t - WHERE t.wishlist_id = $1 AND t.deleted = FALSE + WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE LIMIT 1 - `, item.ID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) + `, item.ID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) if linkedTaskErr == nil && linkedTaskID.Valid { linkedTask := &LinkedTask{ @@ -9544,6 +9569,31 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) // Не возвращаем ошибку, просто не устанавливаем linked_task } + // Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей) + // Исключаем linked_task из подсчета, если она есть + // Учитываем только не закрытые задачи (completed = 0) + var tasksCount int + if linkedTaskID.Valid { + // Если есть linked_task, исключаем её из подсчета + err = a.DB.QueryRow(` + SELECT COUNT(*) + FROM tasks t + WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2 + `, item.ID, linkedTaskID.Int64).Scan(&tasksCount) + } else { + // Если нет linked_task, считаем все не закрытые задачи + err = a.DB.QueryRow(` + SELECT COUNT(*) + FROM tasks t + WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 + `, item.ID).Scan(&tasksCount) + } + if err != nil { + log.Printf("Error counting tasks for wishlist %d: %v", item.ID, err) + tasksCount = 0 + } + item.TasksCount = tasksCount + items = append(items, *item) } @@ -10236,16 +10286,16 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { item.Unlocked = unlocked } - // Загружаем связанную задачу, если есть + // Загружаем связанную задачу текущего пользователя, если есть var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64 var linkedTaskName sql.NullString var linkedTaskNextShowAt sql.NullTime err = a.DB.QueryRow(` SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id FROM tasks t - WHERE t.wishlist_id = $1 AND t.deleted = FALSE + WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE LIMIT 1 - `, itemID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) + `, itemID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) if err == nil && linkedTaskID.Valid { linkedTask := &LinkedTask{ @@ -10267,6 +10317,31 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { // Не возвращаем ошибку, просто не устанавливаем linked_task } + // Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей) + // Исключаем linked_task из подсчета, если она есть + // Учитываем только не закрытые задачи (completed = 0) + var tasksCount int + if linkedTaskID.Valid { + // Если есть linked_task, исключаем её из подсчета + err = a.DB.QueryRow(` + SELECT COUNT(*) + FROM tasks t + WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2 + `, itemID, linkedTaskID.Int64).Scan(&tasksCount) + } else { + // Если нет linked_task, считаем все не закрытые задачи + err = a.DB.QueryRow(` + SELECT COUNT(*) + FROM tasks t + WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 + `, itemID).Scan(&tasksCount) + } + if err != nil { + log.Printf("Error counting tasks for wishlist %d: %v", itemID, err) + tasksCount = 0 + } + item.TasksCount = tasksCount + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(item) } @@ -10780,8 +10855,26 @@ func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) { return } + // Находим задачу пользователя для этого желания, чтобы исключить её из обработки + // (так же, как при закрытии через задачу) + var userTaskID int + err = a.DB.QueryRow(` + SELECT id FROM tasks + WHERE wishlist_id = $1 AND user_id = $2 AND deleted = FALSE + LIMIT 1 + `, itemID, userID).Scan(&userTaskID) + + // Если задача не найдена, используем 0 (не будет исключена, но это нормально, если задачи нет) + if err == sql.ErrNoRows { + userTaskID = 0 + } else if err != nil { + log.Printf("Error finding user task for wishlist item %d: %v", itemID, err) + userTaskID = 0 + } + // Обрабатываем политику награждения для всех задач, связанных с этим желанием - a.processWishlistRewardPolicy(itemID, userID) + // Исключаем задачу пользователя, который закрыл желание (если она есть) + a.processWishlistRewardPolicy(itemID, userTaskID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ @@ -10791,12 +10884,26 @@ func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) { } // processWishlistRewardPolicy обрабатывает политику награждения для всех задач, связанных с желанием -func (a *App) processWishlistRewardPolicy(wishlistItemID int, completingUserID int) { - rows, err := a.DB.Query(` - SELECT id, user_id, reward_policy - FROM tasks - WHERE wishlist_id = $1 AND deleted = FALSE - `, wishlistItemID) +// completedTaskID - ID задачи, которая была закрыта (исключается из обработки). Если 0, задача не найдена, но это нормально +func (a *App) processWishlistRewardPolicy(wishlistItemID int, completedTaskID int) { + var rows *sql.Rows + var err error + if completedTaskID == 0 { + // Если задача не найдена (желание закрывается напрямую, но у пользователя нет задачи), + // обрабатываем все задачи + rows, err = a.DB.Query(` + SELECT id, user_id, reward_policy + FROM tasks + WHERE wishlist_id = $1 AND deleted = FALSE + `, wishlistItemID) + } else { + // Исключаем задачу, которая была закрыта (через задачу или найдена при прямом закрытии желания) + rows, err = a.DB.Query(` + SELECT id, user_id, reward_policy + FROM tasks + WHERE wishlist_id = $1 AND deleted = FALSE AND id != $2 + `, wishlistItemID, completedTaskID) + } if err != nil { log.Printf("Error querying tasks for wishlist item %d: %v", wishlistItemID, err) return @@ -10818,34 +10925,19 @@ func (a *App) processWishlistRewardPolicy(wishlistItemID int, completingUserID i } if policy == "personal" { - // Личная политика: задача выполняется только если пользователь сам завершил желание - if taskUserID == completingUserID { - // Пользователь завершил желание сам - помечаем задачу как выполненную - _, err = a.DB.Exec(` - UPDATE tasks - SET completed = completed + 1, last_completed_at = NOW() - WHERE id = $1 - `, taskID) - if err != nil { - log.Printf("Error completing task %d: %v", taskID, err) - } else { - log.Printf("Task %d completed automatically after wishlist item %d completion (personal policy)", taskID, wishlistItemID) - } + // Личная политика: при закрытии задачи-желания другим пользователем, личная задача удаляется + _, err = a.DB.Exec(` + UPDATE tasks + SET deleted = TRUE + WHERE id = $1 + `, taskID) + if err != nil { + log.Printf("Error deleting task %d: %v", taskID, err) } else { - // Другой пользователь завершил желание - помечаем задачу как удалённую - _, err = a.DB.Exec(` - UPDATE tasks - SET deleted = TRUE - WHERE id = $1 - `, taskID) - if err != nil { - log.Printf("Error deleting task %d: %v", taskID, err) - } else { - log.Printf("Task %d deleted because wishlist item %d was completed by another user (personal policy)", taskID, wishlistItemID) - } + log.Printf("Task %d deleted because wishlist item %d was completed by another user (personal policy)", taskID, wishlistItemID) } } else if policy == "general" { - // Общая политика: задача выполняется независимо от того, кто завершил желание + // Общая политика: при закрытии задачи-желания другим пользователем, общая задача закрывается _, err = a.DB.Exec(` UPDATE tasks SET completed = completed + 1, last_completed_at = NOW() @@ -12418,6 +12510,63 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, } } } + + // Загружаем связанную задачу текущего пользователя, если есть + var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64 + var linkedTaskName sql.NullString + var linkedTaskNextShowAt sql.NullTime + linkedTaskErr := a.DB.QueryRow(` + SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id + FROM tasks t + WHERE t.wishlist_id = $1 AND t.user_id = $2 AND t.deleted = FALSE + LIMIT 1 + `, item.ID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) + + if linkedTaskErr == nil && linkedTaskID.Valid { + linkedTask := &LinkedTask{ + ID: int(linkedTaskID.Int64), + Name: linkedTaskName.String, + Completed: int(linkedTaskCompleted.Int64), + } + if linkedTaskNextShowAt.Valid { + nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339) + linkedTask.NextShowAt = &nextShowAtStr + } + if linkedTaskUserID.Valid { + userIDVal := int(linkedTaskUserID.Int64) + linkedTask.UserID = &userIDVal + } + item.LinkedTask = linkedTask + } else if linkedTaskErr != sql.ErrNoRows { + log.Printf("Error loading linked task for wishlist %d: %v", item.ID, linkedTaskErr) + // Не возвращаем ошибку, просто не устанавливаем linked_task + } + + // Подсчитываем общее количество не закрытых задач для этого желания (всех пользователей) + // Исключаем linked_task из подсчета, если она есть + // Учитываем только не закрытые задачи (completed = 0) + var tasksCount int + if linkedTaskID.Valid { + // Если есть linked_task, исключаем её из подсчета + err = a.DB.QueryRow(` + SELECT COUNT(*) + FROM tasks t + WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 AND t.id != $2 + `, item.ID, linkedTaskID.Int64).Scan(&tasksCount) + } else { + // Если нет linked_task, считаем все не закрытые задачи + err = a.DB.QueryRow(` + SELECT COUNT(*) + FROM tasks t + WHERE t.wishlist_id = $1 AND t.deleted = FALSE AND t.completed = 0 + `, item.ID).Scan(&tasksCount) + } + if err != nil { + log.Printf("Error counting tasks for wishlist %d: %v", item.ID, err) + tasksCount = 0 + } + item.TasksCount = tasksCount + items = append(items, *item) } diff --git a/play-life-backend/migrations/000006_fix_wishlist_id_unique_index.down.sql b/play-life-backend/migrations/000006_fix_wishlist_id_unique_index.down.sql new file mode 100644 index 0000000..f8f100a --- /dev/null +++ b/play-life-backend/migrations/000006_fix_wishlist_id_unique_index.down.sql @@ -0,0 +1,13 @@ +-- Migration: Revert wishlist_id unique index fix +-- Date: 2026-01-30 +-- +-- This migration reverts the composite unique index back to the original +-- unique index that only checked wishlist_id. + +-- Drop the composite unique index +DROP INDEX IF EXISTS idx_tasks_wishlist_id_user_id_unique; + +-- Restore the original unique index on wishlist_id only +CREATE UNIQUE INDEX idx_tasks_wishlist_id_unique +ON tasks(wishlist_id) +WHERE wishlist_id IS NOT NULL AND deleted = FALSE; diff --git a/play-life-backend/migrations/000006_fix_wishlist_id_unique_index.up.sql b/play-life-backend/migrations/000006_fix_wishlist_id_unique_index.up.sql new file mode 100644 index 0000000..26d4bb6 --- /dev/null +++ b/play-life-backend/migrations/000006_fix_wishlist_id_unique_index.up.sql @@ -0,0 +1,16 @@ +-- Migration: Fix wishlist_id unique index to allow multiple users +-- Date: 2026-01-30 +-- +-- This migration fixes the unique index on wishlist_id to allow multiple users +-- to create tasks for the same wishlist item. The old index only checked wishlist_id, +-- but now we need a composite unique index on (wishlist_id, user_id). + +-- Drop the old unique index that only checked wishlist_id +DROP INDEX IF EXISTS idx_tasks_wishlist_id_unique; + +-- Create a new composite unique index on (wishlist_id, user_id) +-- This allows multiple users to have tasks for the same wishlist item, +-- but prevents the same user from having multiple tasks for the same wishlist item +CREATE UNIQUE INDEX idx_tasks_wishlist_id_user_id_unique +ON tasks(wishlist_id, user_id) +WHERE wishlist_id IS NOT NULL AND deleted = FALSE; diff --git a/play-life-web/package.json b/play-life-web/package.json index 1141105..833083f 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "4.4.1", + "version": "4.5.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/Wishlist.css b/play-life-web/src/components/Wishlist.css index 9ba3c7c..a6314a4 100644 --- a/play-life-web/src/components/Wishlist.css +++ b/play-life-web/src/components/Wishlist.css @@ -167,6 +167,33 @@ border-radius: 18px; } +.card-tasks-badge { + position: absolute; + bottom: 0.5rem; + background: #6b7280; + color: white; + border-radius: 50%; + padding: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + min-width: 1.5rem; + height: 1.5rem; + text-align: center; + z-index: 5; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + display: flex; + align-items: center; + justify-content: center; +} + +.card-tasks-badge-left { + left: 0.5rem; +} + +.card-tasks-badge-right { + right: 0.5rem; +} + .card-name { padding: 0.6rem 0 0; font-weight: 600; diff --git a/play-life-web/src/components/WishlistDetail.css b/play-life-web/src/components/WishlistDetail.css index cc31be8..3e26fd3 100644 --- a/play-life-web/src/components/WishlistDetail.css +++ b/play-life-web/src/components/WishlistDetail.css @@ -232,6 +232,27 @@ transform: translateY(-1px); } +.wishlist-detail-tasks-badge { + position: absolute; + bottom: -0.25rem; + left: -0.25rem; + background: #6b7280; + color: white; + border-radius: 50%; + padding: 0.2rem; + font-size: 0.7rem; + font-weight: 600; + min-width: 1.25rem; + height: 1.25rem; + text-align: center; + z-index: 10; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + border: 2px solid white; + display: flex; + align-items: center; + justify-content: center; +} + .wishlist-detail-linked-task { margin-top: 0.75rem; } diff --git a/play-life-web/src/components/WishlistDetail.jsx b/play-life-web/src/components/WishlistDetail.jsx index 52271a6..aab9884 100644 --- a/play-life-web/src/components/WishlistDetail.jsx +++ b/play-life-web/src/components/WishlistDetail.jsx @@ -368,11 +368,12 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) { {wishlistItem.linked_task && wishlistItem.linked_task.user_id === user?.id ? (
Связанная задача:
-
-
+
+
+
+ {wishlistItem?.tasks_count > 0 && ( +
+ {wishlistItem.tasks_count} +
+ )} +
) : (
@@ -459,16 +466,23 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) { > {isCompleting ? 'Завершение...' : 'Завершить'} - +
+ + {wishlistItem?.tasks_count > 0 && ( +
+ {wishlistItem.tasks_count} +
+ )} +
)}