diff --git a/VERSION b/VERSION index a6d9ada..3f0a10f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.14.5 +3.14.6 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index 372229c..90125fe 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -334,6 +334,7 @@ type WishlistRequest struct { } type UnlockConditionRequest struct { + ID *int `json:"id,omitempty"` // ID существующего условия (для сохранения чужих условий) Type string `json:"type"` TaskID *int `json:"task_id,omitempty"` ProjectID *int `json:"project_id,omitempty"` @@ -2865,6 +2866,12 @@ func (a *App) initAuthDB() error { // Не возвращаем ошибку, чтобы приложение могло запуститься } + // Apply migration 025: Remove wishlist conditions without user_id + if err := a.applyMigration025(); err != nil { + log.Printf("Warning: Failed to apply migration 025: %v", err) + // Не возвращаем ошибку, чтобы приложение могло запуститься + } + // Clean up expired refresh tokens (only those with expiration date set) a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()") @@ -3268,6 +3275,50 @@ func (a *App) applyMigration024() error { return nil } +// applyMigration025 применяет миграцию 025_remove_conditions_without_user_id.sql +func (a *App) applyMigration025() error { + log.Printf("Applying migration 025: Remove wishlist conditions without user_id") + + // Проверяем, есть ли условия без user_id + var count int + err := a.DB.QueryRow(` + SELECT COUNT(*) + FROM wishlist_conditions + WHERE user_id IS NULL + `).Scan(&count) + + if err != nil { + return fmt.Errorf("failed to check conditions without user_id: %w", err) + } + + if count == 0 { + log.Printf("Migration 025 already applied (no conditions without user_id), skipping") + return nil + } + + log.Printf("Found %d conditions without user_id, removing them", count) + + // Читаем SQL из файла миграции + migrationPath := "migrations/025_remove_conditions_without_user_id.sql" + if _, err := os.Stat(migrationPath); os.IsNotExist(err) { + // Пробуем альтернативный путь (в Docker) + migrationPath = "/migrations/025_remove_conditions_without_user_id.sql" + } + + migrationSQL, err := os.ReadFile(migrationPath) + if err != nil { + return fmt.Errorf("failed to read migration file %s: %w", migrationPath, err) + } + + // Выполняем миграцию + if _, err := a.DB.Exec(string(migrationSQL)); err != nil { + return fmt.Errorf("failed to execute migration 025: %w", err) + } + + log.Printf("Migration 025 applied successfully, removed %d conditions without user_id", count) + return nil +} + func (a *App) initPlayLifeDB() error { // Создаем таблицу projects createProjectsTable := ` @@ -9372,7 +9423,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id - LEFT JOIN projects p ON sc.project_id = p.id + LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE WHERE wi.user_id = $1 AND wi.deleted = FALSE AND ($2 = TRUE OR wi.completed = FALSE) @@ -9457,6 +9508,10 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) if projectName.Valid { condition.ProjectName = &projectName.String } + if projectID.Valid { + projectIDVal := int(projectID.Int64) + condition.ProjectID = &projectIDVal + } if requiredPoints.Valid { condition.RequiredPoints = &requiredPoints.Float64 } @@ -9633,18 +9688,43 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) } // saveWishlistConditions сохраняет условия для желания +// userID - автор условий (пользователь, который создает/обновляет условия) func (a *App) saveWishlistConditions( tx *sql.Tx, wishlistItemID int, + userID int, conditions []UnlockConditionRequest, ) error { - // Удаляем старые условия - _, err := tx.Exec(` - DELETE FROM wishlist_conditions + // Получаем все существующие условия с их user_id перед удалением + existingConditions := make(map[int]int) // map[conditionID]userID + rows, err := tx.Query(` + SELECT id, user_id + FROM wishlist_conditions WHERE wishlist_item_id = $1 `, wishlistItemID) if err != nil { - return err + return fmt.Errorf("error getting existing conditions: %w", err) + } + defer rows.Close() + + for rows.Next() { + var condID int + var condUserID sql.NullInt64 + if err := rows.Scan(&condID, &condUserID); err != nil { + return fmt.Errorf("error scanning existing condition: %w", err) + } + if condUserID.Valid { + existingConditions[condID] = int(condUserID.Int64) + } + } + + // Удаляем только условия текущего пользователя + _, err = tx.Exec(` + DELETE FROM wishlist_conditions + WHERE wishlist_item_id = $1 AND user_id = $2 + `, wishlistItemID, userID) + if err != nil { + return fmt.Errorf("error deleting user conditions: %w", err) } if len(conditions) == 0 { @@ -9654,8 +9734,8 @@ func (a *App) saveWishlistConditions( // Подготавливаем statement для вставки условий stmt, err := tx.Prepare(` INSERT INTO wishlist_conditions - (wishlist_item_id, task_condition_id, score_condition_id, display_order) - VALUES ($1, $2, $3, $4) + (wishlist_item_id, user_id, task_condition_id, score_condition_id, display_order) + VALUES ($1, $2, $3, $4, $5) `) if err != nil { return err @@ -9743,9 +9823,20 @@ func (a *App) saveWishlistConditions( scoreConditionID = scID } + // Определяем user_id для условия: + // - Если условие имеет id и это условие существовало - используем его оригинальный user_id + // - Иначе - используем userID текущего пользователя + conditionUserID := userID + if condition.ID != nil { + if originalUserID, exists := existingConditions[*condition.ID]; exists { + conditionUserID = originalUserID + } + } + // Создаём связь _, err = stmt.Exec( wishlistItemID, + conditionUserID, taskConditionID, scoreConditionID, displayOrder, @@ -9937,7 +10028,7 @@ func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) { // Сохраняем условия if len(req.UnlockConditions) > 0 { - err = a.saveWishlistConditions(tx, wishlistID, req.UnlockConditions) + err = a.saveWishlistConditions(tx, wishlistID, userID, req.UnlockConditions) if err != nil { log.Printf("Error saving wishlist conditions: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError) @@ -10088,7 +10179,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id - LEFT JOIN projects p ON sc.project_id = p.id + LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE WHERE wi.id = $1 AND wi.deleted = FALSE ORDER BY wc.display_order, wc.id @@ -10185,6 +10276,12 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { if projectName.Valid { condition.ProjectName = &projectName.String } + if projectID.Valid { + projectIDVal := int(projectID.Int64) + condition.ProjectID = &projectIDVal + points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID) + condition.CurrentPoints = &points + } if requiredPoints.Valid { condition.RequiredPoints = &requiredPoints.Float64 } @@ -10192,10 +10289,6 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } - if projectID.Valid { - points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID) - condition.CurrentPoints = &points - } } item.UnlockConditions = append(item.UnlockConditions, condition) @@ -10358,7 +10451,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { } // Сохраняем условия - err = a.saveWishlistConditions(tx, itemID, req.UnlockConditions) + err = a.saveWishlistConditions(tx, itemID, userID, req.UnlockConditions) if err != nil { log.Printf("Error saving wishlist conditions: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError) @@ -10397,7 +10490,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id - LEFT JOIN projects p ON sc.project_id = p.id + LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE WHERE wi.id = $1 AND wi.deleted = FALSE ORDER BY wc.display_order, wc.id @@ -10500,6 +10593,12 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { if projectName.Valid { condition.ProjectName = &projectName.String } + if projectID.Valid { + projectIDVal := int(projectID.Int64) + condition.ProjectID = &projectIDVal + points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID) + condition.CurrentPoints = &points + } if requiredPoints.Valid { condition.RequiredPoints = &requiredPoints.Float64 } @@ -10507,10 +10606,6 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) { dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } - if projectID.Valid { - points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID) - condition.CurrentPoints = &points - } } item.UnlockConditions = append(item.UnlockConditions, condition) @@ -10944,11 +11039,13 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { var link sql.NullString var imagePath sql.NullString var ownerID int + var boardID sql.NullInt64 + var authorID sql.NullInt64 err = a.DB.QueryRow(` - SELECT user_id, name, price, link, image_path + SELECT user_id, name, price, link, image_path, board_id, author_id FROM wishlist_items WHERE id = $1 AND deleted = FALSE - `, itemID).Scan(&ownerID, &name, &price, &link, &imagePath) + `, itemID).Scan(&ownerID, &name, &price, &link, &imagePath, &boardID, &authorID) if err == sql.ErrNoRows || ownerID != userID { sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound) @@ -11039,11 +11136,23 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { linkVal = link.String } + // Определяем значения для board_id и author_id + var boardIDVal, authorIDVal interface{} + if boardID.Valid { + boardIDVal = int(boardID.Int64) + } + if authorID.Valid { + authorIDVal = int(authorID.Int64) + } else { + // Если author_id не был установлен, используем текущего пользователя + authorIDVal = userID + } + err = tx.QueryRow(` - INSERT INTO wishlist_items (user_id, name, price, link, completed, deleted) - VALUES ($1, $2, $3, $4, FALSE, FALSE) + INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, completed, deleted) + VALUES ($1, $2, $3, $4, $5, $6, FALSE, FALSE) RETURNING id - `, userID, name+" (копия)", priceVal, linkVal).Scan(&newWishlistID) + `, ownerID, boardIDVal, authorIDVal, name+" (копия)", priceVal, linkVal).Scan(&newWishlistID) if err != nil { log.Printf("Error creating wishlist copy: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist copy: %v", err), http.StatusInternalServerError) @@ -11052,7 +11161,7 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { // Сохраняем условия if len(conditions) > 0 { - err = a.saveWishlistConditions(tx, newWishlistID, conditions) + err = a.saveWishlistConditions(tx, newWishlistID, userID, conditions) if err != nil { log.Printf("Error saving wishlist conditions: %v", err) sendErrorWithCORS(w, fmt.Sprintf("Error saving wishlist conditions: %v", err), http.StatusInternalServerError) @@ -12022,7 +12131,7 @@ func (a *App) getBoardCompletedHandler(w http.ResponseWriter, r *http.Request) { LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id - LEFT JOIN projects p ON sc.project_id = p.id + LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE LEFT JOIN users u ON wc.user_id = u.id WHERE wi.board_id = $1 AND wi.deleted = FALSE @@ -12178,6 +12287,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, wi.image_path, wi.link, wi.completed, + COALESCE(wi.author_id, wi.user_id) AS item_owner_id, wc.id AS condition_id, wc.display_order, wc.task_condition_id, @@ -12194,7 +12304,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, LEFT JOIN task_conditions tc ON wc.task_condition_id = tc.id LEFT JOIN tasks t ON tc.task_id = t.id LEFT JOIN score_conditions sc ON wc.score_condition_id = sc.id - LEFT JOIN projects p ON sc.project_id = p.id + LEFT JOIN projects p ON sc.project_id = p.id AND p.deleted = FALSE WHERE wi.board_id = $1 AND wi.deleted = FALSE AND wi.completed = FALSE @@ -12216,6 +12326,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, var imagePath sql.NullString var link sql.NullString var completed bool + var itemOwnerID sql.NullInt64 var conditionID sql.NullInt64 var displayOrder sql.NullInt64 var taskConditionID sql.NullInt64 @@ -12229,7 +12340,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, var startDate sql.NullTime err := rows.Scan( - &itemID, &name, &price, &imagePath, &link, &completed, + &itemID, &name, &price, &imagePath, &link, &completed, &itemOwnerID, &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID, &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, ) @@ -12268,8 +12379,12 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, DisplayOrder: int(displayOrder.Int64), } - // Используем user_id из условия, если он есть, иначе используем текущего пользователя - conditionOwnerID := userID + // Используем user_id из условия, если он есть, иначе используем владельца желания + if !itemOwnerID.Valid { + log.Printf("Warning: item_owner_id is NULL for wishlist item %d, skipping condition", itemID) + continue + } + conditionOwnerID := int(itemOwnerID.Int64) if conditionUserID.Valid { conditionOwnerID = int(conditionUserID.Int64) } @@ -12291,6 +12406,13 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, if projectName.Valid { condition.ProjectName = &projectName.String } + if projectID.Valid { + projectIDVal := int(projectID.Int64) + condition.ProjectID = &projectIDVal + // Считаем текущие баллы для владельца условия + points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID) + condition.CurrentPoints = &points + } if requiredPoints.Valid { condition.RequiredPoints = &requiredPoints.Float64 } @@ -12298,11 +12420,6 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, dateStr := startDate.Time.Format("2006-01-02") condition.StartDate = &dateStr } - // Считаем текущие баллы для владельца условия - if projectID.Valid { - points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, conditionOwnerID) - condition.CurrentPoints = &points - } } item.UnlockConditions = append(item.UnlockConditions, condition) diff --git a/play-life-backend/migrations/025_remove_conditions_without_user_id.sql b/play-life-backend/migrations/025_remove_conditions_without_user_id.sql new file mode 100644 index 0000000..e19b420 --- /dev/null +++ b/play-life-backend/migrations/025_remove_conditions_without_user_id.sql @@ -0,0 +1,13 @@ +-- Migration: Remove wishlist conditions without user_id +-- These conditions should not exist as every condition must have an owner +-- This migration removes orphaned conditions that were created before the fix + +-- ============================================ +-- Remove conditions without user_id +-- ============================================ +DELETE FROM wishlist_conditions WHERE user_id IS NULL; + +-- ============================================ +-- Comments +-- ============================================ +COMMENT ON COLUMN wishlist_conditions.user_id IS 'Owner of this condition. Each user has their own goals on shared boards. Required field.'; diff --git a/play-life-web/package.json b/play-life-web/package.json index 6c7c0eb..919ec67 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.14.5", + "version": "3.14.6", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/components/Wishlist.jsx b/play-life-web/src/components/Wishlist.jsx index 792af13..2c0bd76 100644 --- a/play-life-web/src/components/Wishlist.jsx +++ b/play-life-web/src/components/Wishlist.jsx @@ -450,15 +450,31 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoa }) if (!response.ok) { - throw new Error('Ошибка при копировании') + const errorText = await response.text().catch(() => '') + throw new Error(errorText || 'Ошибка при копировании') } const newItem = await response.json() setSelectedItem(null) + + // Очищаем кэш для текущей доски, чтобы новое желание появилось в списке + if (selectedBoardId) { + try { + localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`) + } catch (err) { + console.error('Error clearing cache:', err) + } + } + + // Обновляем список + await fetchItems() + + // Открываем форму редактирования для нового желания onNavigate?.('wishlist-form', { wishlistId: newItem.id, boardId: selectedBoardId }) } catch (err) { - setError(err.message) + console.error('Error copying wishlist item:', err) + setError(err.message || 'Ошибка при копировании') setSelectedItem(null) } } diff --git a/play-life-web/src/components/WishlistForm.jsx b/play-life-web/src/components/WishlistForm.jsx index 7218fb3..460e4ef 100644 --- a/play-life-web/src/components/WishlistForm.jsx +++ b/play-life-web/src/components/WishlistForm.jsx @@ -88,9 +88,12 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b setImageUrl(data.image_url || null) if (data.unlock_conditions) { setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({ + id: cond.id || null, type: cond.type, - task_id: cond.type === 'task_completion' ? tasks.find(t => t.name === cond.task_name)?.id : null, - project_id: cond.type === 'project_points' ? projects.find(p => p.project_name === cond.project_name)?.project_id : null, + task_id: cond.type === 'task_completion' ? (cond.task_id || tasks.find(t => t.name === cond.task_name)?.id) : null, + task_name: cond.task_name || null, + project_id: cond.type === 'project_points' ? (cond.project_id || projects.find(p => p.project_name === cond.project_name)?.project_id) : null, + project_name: cond.project_name || null, required_points: cond.required_points || null, start_date: cond.start_date || null, display_order: idx, @@ -239,9 +242,12 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b setImageFile(null) // Сбрасываем imageFile при загрузке существующего желания if (data.unlock_conditions) { setUnlockConditions(data.unlock_conditions.map((cond, idx) => ({ + id: cond.id || null, type: cond.type, - task_id: cond.type === 'task_completion' ? tasks.find(t => t.name === cond.task_name)?.id : null, - project_id: cond.type === 'project_points' ? projects.find(p => p.project_name === cond.project_name)?.project_id : null, + task_id: cond.type === 'task_completion' ? (cond.task_id || tasks.find(t => t.name === cond.task_name)?.id) : null, + task_name: cond.task_name || null, + project_id: cond.type === 'project_points' ? (cond.project_id || projects.find(p => p.project_name === cond.project_name)?.project_id) : null, + project_name: cond.project_name || null, required_points: cond.required_points || null, start_date: cond.start_date || null, display_order: idx, @@ -531,6 +537,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b price: price ? parseFloat(price) : null, link: link.trim() || null, unlock_conditions: unlockConditions.map(cond => ({ + id: cond.id || null, type: cond.type, task_id: cond.type === 'task_completion' ? cond.task_id : null, project_id: cond.type === 'project_points' ? cond.project_id : null, @@ -784,7 +791,7 @@ function WishlistForm({ onNavigate, wishlistId, editConditionIndex, newTaskId, b > {cond.type === 'task_completion' ? `Задача: ${tasks.find(t => t.id === cond.task_id)?.name || 'Не выбрана'}` - : `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`} + : `Баллы: ${cond.required_points} в ${projects.find(p => p.project_id === cond.project_id)?.project_name || cond.project_name || 'Не выбран'}${cond.start_date ? ` с ${new Date(cond.start_date + 'T00:00:00').toLocaleDateString('ru-RU')}` : ' за всё время'}`}