4.5.0: Улучшена работа с задачами желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s

This commit is contained in:
poignatov
2026-01-30 19:53:13 +03:00
parent 25f0c2697a
commit e955494dc8
8 changed files with 302 additions and 62 deletions

View File

@@ -1 +1 @@
4.4.1 4.5.0

View File

@@ -362,6 +362,7 @@ type WishlistItem struct {
MoreLockedConditions int `json:"more_locked_conditions,omitempty"` MoreLockedConditions int `json:"more_locked_conditions,omitempty"`
UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"` UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
LinkedTask *LinkedTask `json:"linked_task,omitempty"` LinkedTask *LinkedTask `json:"linked_task,omitempty"`
TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания
} }
type UnlockConditionDisplay struct { type UnlockConditionDisplay struct {
@@ -7170,12 +7171,14 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
// Проверяем, что нет другой задачи с таким wishlist_id // Проверяем, что нет другой активной (не удаленной и не выполненной) задачи с таким wishlist_id для этого пользователя
// Если задача была выполнена (completed > 0) или удалена, можно создать новую
var existingTaskID int var existingTaskID int
var existingTaskCompleted int
err = a.DB.QueryRow(` err = a.DB.QueryRow(`
SELECT id FROM tasks SELECT id, completed FROM tasks
WHERE wishlist_id = $1 AND deleted = FALSE WHERE wishlist_id = $1 AND user_id = $2 AND deleted = FALSE
`, *req.WishlistID).Scan(&existingTaskID) `, *req.WishlistID, userID).Scan(&existingTaskID, &existingTaskCompleted)
if err != sql.ErrNoRows { if err != sql.ErrNoRows {
if err != nil { if err != nil {
@@ -7183,9 +7186,25 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
sendErrorWithCORS(w, fmt.Sprintf("Error checking existing task: %v", err), http.StatusInternalServerError) sendErrorWithCORS(w, fmt.Sprintf("Error checking existing task: %v", err), http.StatusInternalServerError)
return 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) sendErrorWithCORS(w, "Task already exists for this wishlist item", http.StatusBadRequest)
return return
} }
}
// Если название задачи не указано или пустое, используем название желания // Если название задачи не указано или пустое, используем название желания
if strings.TrimSpace(req.Name) == "" { if strings.TrimSpace(req.Name) == "" {
@@ -7340,6 +7359,11 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("Error creating task: %v", err) 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) sendErrorWithCORS(w, fmt.Sprintf("Error creating task: %v", err), http.StatusInternalServerError)
return return
} }
@@ -8697,7 +8721,8 @@ func (a *App) executeTask(taskID int, userID int, req CompleteTaskRequest) error
} else { } else {
log.Printf("Wishlist item %d completed automatically after task %d completion", wishlistID.Int64, taskID) 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 linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
var linkedTaskName sql.NullString var linkedTaskName sql.NullString
var linkedTaskNextShowAt sql.NullTime var linkedTaskNextShowAt sql.NullTime
linkedTaskErr := a.DB.QueryRow(` linkedTaskErr := a.DB.QueryRow(`
SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id
FROM tasks t 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 LIMIT 1
`, item.ID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) `, item.ID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID)
if linkedTaskErr == nil && linkedTaskID.Valid { if linkedTaskErr == nil && linkedTaskID.Valid {
linkedTask := &LinkedTask{ linkedTask := &LinkedTask{
@@ -9544,6 +9569,31 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
// Не возвращаем ошибку, просто не устанавливаем linked_task // Не возвращаем ошибку, просто не устанавливаем 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) items = append(items, *item)
} }
@@ -10236,16 +10286,16 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
item.Unlocked = unlocked item.Unlocked = unlocked
} }
// Загружаем связанную задачу, если есть // Загружаем связанную задачу текущего пользователя, если есть
var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64 var linkedTaskID, linkedTaskCompleted, linkedTaskUserID sql.NullInt64
var linkedTaskName sql.NullString var linkedTaskName sql.NullString
var linkedTaskNextShowAt sql.NullTime var linkedTaskNextShowAt sql.NullTime
err = a.DB.QueryRow(` err = a.DB.QueryRow(`
SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id SELECT t.id, t.name, t.completed, t.next_show_at, t.user_id
FROM tasks t 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 LIMIT 1
`, itemID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) `, itemID, userID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID)
if err == nil && linkedTaskID.Valid { if err == nil && linkedTaskID.Valid {
linkedTask := &LinkedTask{ linkedTask := &LinkedTask{
@@ -10267,6 +10317,31 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
// Не возвращаем ошибку, просто не устанавливаем linked_task // Не возвращаем ошибку, просто не устанавливаем 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(item) json.NewEncoder(w).Encode(item)
} }
@@ -10780,8 +10855,26 @@ func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) {
return 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{
@@ -10791,12 +10884,26 @@ func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) {
} }
// processWishlistRewardPolicy обрабатывает политику награждения для всех задач, связанных с желанием // processWishlistRewardPolicy обрабатывает политику награждения для всех задач, связанных с желанием
func (a *App) processWishlistRewardPolicy(wishlistItemID int, completingUserID int) { // completedTaskID - ID задачи, которая была закрыта (исключается из обработки). Если 0, задача не найдена, но это нормально
rows, err := a.DB.Query(` 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 SELECT id, user_id, reward_policy
FROM tasks FROM tasks
WHERE wishlist_id = $1 AND deleted = FALSE WHERE wishlist_id = $1 AND deleted = FALSE
`, wishlistItemID) `, 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 { if err != nil {
log.Printf("Error querying tasks for wishlist item %d: %v", wishlistItemID, err) log.Printf("Error querying tasks for wishlist item %d: %v", wishlistItemID, err)
return return
@@ -10818,21 +10925,7 @@ func (a *App) processWishlistRewardPolicy(wishlistItemID int, completingUserID i
} }
if policy == "personal" { 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)
}
} else {
// Другой пользователь завершил желание - помечаем задачу как удалённую
_, err = a.DB.Exec(` _, err = a.DB.Exec(`
UPDATE tasks UPDATE tasks
SET deleted = TRUE SET deleted = TRUE
@@ -10843,9 +10936,8 @@ func (a *App) processWishlistRewardPolicy(wishlistItemID int, completingUserID i
} else { } 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" { } else if policy == "general" {
// Общая политика: задача выполняется независимо от того, кто завершил желание // Общая политика: при закрытии задачи-желания другим пользователем, общая задача закрывается
_, err = a.DB.Exec(` _, err = a.DB.Exec(`
UPDATE tasks UPDATE tasks
SET completed = completed + 1, last_completed_at = NOW() 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) items = append(items, *item)
} }

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,6 +1,6 @@
{ {
"name": "play-life-web", "name": "play-life-web",
"version": "4.4.1", "version": "4.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -167,6 +167,33 @@
border-radius: 18px; 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 { .card-name {
padding: 0.6rem 0 0; padding: 0.6rem 0 0;
font-weight: 600; font-weight: 600;

View File

@@ -232,6 +232,27 @@
transform: translateY(-1px); 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 { .wishlist-detail-linked-task {
margin-top: 0.75rem; margin-top: 0.75rem;
} }

View File

@@ -368,6 +368,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) {
{wishlistItem.linked_task && wishlistItem.linked_task.user_id === user?.id ? ( {wishlistItem.linked_task && wishlistItem.linked_task.user_id === user?.id ? (
<div className="wishlist-detail-linked-task"> <div className="wishlist-detail-linked-task">
<div className="linked-task-label-header">Связанная задача:</div> <div className="linked-task-label-header">Связанная задача:</div>
<div style={{ position: 'relative', display: 'inline-block', width: '100%' }}>
<div <div
className="task-item" className="task-item"
onClick={handleTaskItemClick} onClick={handleTaskItemClick}
@@ -439,6 +440,12 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) {
)} )}
</div> </div>
</div> </div>
{wishlistItem?.tasks_count > 0 && (
<div className="wishlist-detail-tasks-badge">
{wishlistItem.tasks_count}
</div>
)}
</div>
</div> </div>
) : ( ) : (
<div className="wishlist-detail-actions"> <div className="wishlist-detail-actions">
@@ -459,6 +466,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) {
> >
{isCompleting ? 'Завершение...' : 'Завершить'} {isCompleting ? 'Завершение...' : 'Завершить'}
</button> </button>
<div style={{ position: 'relative', display: 'inline-block' }}>
<button <button
onClick={handleCreateTask} onClick={handleCreateTask}
className="wishlist-detail-create-task-button" className="wishlist-detail-create-task-button"
@@ -469,6 +477,12 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh, boardId }) {
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path> <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg> </svg>
</button> </button>
{wishlistItem?.tasks_count > 0 && (
<div className="wishlist-detail-tasks-badge">
{wishlistItem.tasks_count}
</div>
)}
</div>
</> </>
)} )}
</div> </div>