diff --git a/VERSION b/VERSION index 8531a3b..4eba2a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.12.2 +3.13.0 diff --git a/play-life-backend/main.go b/play-life-backend/main.go index b8773b8..3a810ef 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -212,6 +212,7 @@ type Task struct { RepetitionDate *string `json:"repetition_date,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"` ConfigID *int `json:"config_id,omitempty"` + RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями // Дополнительные поля для списка задач (без omitempty чтобы всегда передавались) ProjectNames []string `json:"project_names"` SubtasksCount int `json:"subtasks_count"` @@ -262,6 +263,7 @@ type TaskRequest struct { RepetitionPeriod *string `json:"repetition_period,omitempty"` RepetitionDate *string `json:"repetition_date,omitempty"` WishlistID *int `json:"wishlist_id,omitempty"` + RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями Rewards []RewardRequest `json:"rewards,omitempty"` Subtasks []SubtaskRequest `json:"subtasks,omitempty"` // Test-specific fields @@ -289,6 +291,7 @@ type LinkedTask struct { Name string `json:"name"` Completed int `json:"completed"` NextShowAt *string `json:"next_show_at,omitempty"` + UserID *int `json:"user_id,omitempty"` // ID пользователя-владельца задачи } type WishlistItem struct { @@ -308,7 +311,9 @@ type WishlistItem struct { type UnlockConditionDisplay struct { ID int `json:"id"` Type string `json:"type"` + TaskID *int `json:"task_id,omitempty"` // ID задачи (для task_completion) TaskName *string `json:"task_name,omitempty"` + 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 = за всё время @@ -316,6 +321,9 @@ type UnlockConditionDisplay struct { // Прогресс выполнения CurrentPoints *float64 `json:"current_points,omitempty"` // Текущее количество баллов (для project_points) TaskCompleted *bool `json:"task_completed,omitempty"` // Выполнена ли задача (для task_completion) + // Персональные цели + UserID *int `json:"user_id,omitempty"` // ID пользователя для персональных целей + UserName *string `json:"user_name,omitempty"` // Имя пользователя для персональных целей } type WishlistRequest struct { @@ -341,6 +349,48 @@ type WishlistResponse struct { CompletedCount int `json:"completed_count"` // Количество завершённых желаний } +// ============================================ +// Wishlist Boards (доски желаний) +// ============================================ + +type WishlistBoard struct { + ID int `json:"id"` + OwnerID int `json:"owner_id"` + OwnerName string `json:"owner_name,omitempty"` + Name string `json:"name"` + InviteEnabled bool `json:"invite_enabled"` + InviteToken *string `json:"invite_token,omitempty"` + InviteURL *string `json:"invite_url,omitempty"` + MemberCount int `json:"member_count"` + IsOwner bool `json:"is_owner"` + CreatedAt time.Time `json:"created_at"` +} + +type BoardMember struct { + ID int `json:"id"` + UserID int `json:"user_id"` + Name string `json:"name"` + Email string `json:"email"` + JoinedAt time.Time `json:"joined_at"` +} + +type BoardRequest struct { + Name string `json:"name"` + InviteEnabled *bool `json:"invite_enabled,omitempty"` +} + +type BoardInviteInfo struct { + BoardID int `json:"board_id"` + Name string `json:"name"` + OwnerName string `json:"owner_name"` + MemberCount int `json:"member_count"` +} + +type JoinBoardResponse struct { + Board WishlistBoard `json:"board"` + Message string `json:"message"` +} + // ============================================ // Helper functions for repetition_date // ============================================ @@ -2803,6 +2853,18 @@ func (a *App) initAuthDB() error { // Не возвращаем ошибку, чтобы приложение могло запуститься } + // Apply migration 023: Add wishlist boards + if err := a.applyMigration023(); err != nil { + log.Printf("Warning: Failed to apply migration 023: %v", err) + // Не возвращаем ошибку, чтобы приложение могло запуститься + } + + // Apply migration 024: Add reward_policy to tasks + if err := a.applyMigration024(); err != nil { + log.Printf("Warning: Failed to apply migration 024: %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()") @@ -3117,6 +3179,95 @@ func (a *App) applyMigration022() error { return nil } +// applyMigration023 применяет миграцию 023_add_wishlist_boards.sql +func (a *App) applyMigration023() error { + log.Printf("Applying migration 023: Add wishlist boards") + + // Проверяем, существует ли уже таблица wishlist_boards + var exists bool + err := a.DB.QueryRow(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'wishlist_boards' + ) + `).Scan(&exists) + + if err != nil { + return fmt.Errorf("failed to check wishlist_boards table existence: %w", err) + } + + if exists { + log.Printf("Migration 023 already applied (wishlist_boards table exists), skipping") + return nil + } + + // Читаем SQL из файла миграции + migrationPath := "migrations/023_add_wishlist_boards.sql" + if _, err := os.Stat(migrationPath); os.IsNotExist(err) { + // Пробуем альтернативный путь (в Docker) + migrationPath = "/migrations/023_add_wishlist_boards.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 023: %w", err) + } + + log.Printf("Migration 023 applied successfully") + return nil +} + +// applyMigration024 применяет миграцию 024_add_reward_policy.sql +func (a *App) applyMigration024() error { + log.Printf("Applying migration 024: Add reward_policy to tasks") + + // Проверяем, существует ли уже колонка reward_policy + var exists bool + err := a.DB.QueryRow(` + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'tasks' + AND column_name = 'reward_policy' + ) + `).Scan(&exists) + + if err != nil { + return fmt.Errorf("failed to check reward_policy column existence: %w", err) + } + + if exists { + log.Printf("Migration 024 already applied (reward_policy column exists), skipping") + return nil + } + + // Читаем SQL из файла миграции + migrationPath := "migrations/024_add_reward_policy.sql" + if _, err := os.Stat(migrationPath); os.IsNotExist(err) { + // Пробуем альтернативный путь (в Docker) + migrationPath = "/migrations/024_add_reward_policy.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 024: %w", err) + } + + log.Printf("Migration 024 applied successfully") + return nil +} + func (a *App) initPlayLifeDB() error { // Создаем таблицу projects createProjectsTable := ` @@ -4058,6 +4209,24 @@ func main() { protected.HandleFunc("/api/wishlist", app.createWishlistHandler).Methods("POST", "OPTIONS") protected.HandleFunc("/api/wishlist/completed", app.getWishlistCompletedHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/wishlist/metadata", app.extractLinkMetadataHandler).Methods("POST", "OPTIONS") + + // Wishlist Boards (ВАЖНО: должны быть ПЕРЕД /api/wishlist/{id} чтобы избежать конфликта роутов!) + protected.HandleFunc("/api/wishlist/boards", app.getBoardsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards", app.createBoardHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards/{id}", app.getBoardHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards/{id}", app.updateBoardHandler).Methods("PUT", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards/{id}", app.deleteBoardHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards/{id}/regenerate-invite", app.regenerateBoardInviteHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards/{id}/members", app.getBoardMembersHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards/{id}/members/{userId}", app.removeBoardMemberHandler).Methods("DELETE", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards/{id}/leave", app.leaveBoardHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.getBoardItemsHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards/{boardId}/items", app.createBoardItemHandler).Methods("POST", "OPTIONS") + protected.HandleFunc("/api/wishlist/boards/{boardId}/completed", app.getBoardCompletedHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/wishlist/invite/{token}", app.getBoardInviteInfoHandler).Methods("GET", "OPTIONS") + protected.HandleFunc("/api/wishlist/invite/{token}/join", app.joinBoardHandler).Methods("POST", "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") protected.HandleFunc("/api/wishlist/{id}", app.deleteWishlistHandler).Methods("DELETE", "OPTIONS") @@ -6745,6 +6914,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { t.progression_base, t.wishlist_id, t.config_id, + t.reward_policy, COALESCE(( SELECT COUNT(*) FROM tasks st @@ -6790,6 +6960,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { var progressionBase sql.NullFloat64 var wishlistID sql.NullInt64 var configID sql.NullInt64 + var rewardPolicy sql.NullString var projectNames pq.StringArray var subtaskProjectNames pq.StringArray @@ -6804,6 +6975,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { &progressionBase, &wishlistID, &configID, + &rewardPolicy, &task.SubtasksCount, &projectNames, &subtaskProjectNames, @@ -6839,6 +7011,9 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) { configIDInt := int(configID.Int64) task.ConfigID = &configIDInt } + if rewardPolicy.Valid { + task.RewardPolicy = &rewardPolicy.String + } // Объединяем проекты из основной задачи и подзадач allProjects := make(map[string]bool) @@ -6897,6 +7072,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { var repetitionDate sql.NullString var wishlistID sql.NullInt64 var configID sql.NullInt64 + var rewardPolicy sql.NullString // Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL var repetitionPeriodStr string @@ -6906,11 +7082,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) { CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period, COALESCE(repetition_date, '') as repetition_date, wishlist_id, - config_id + config_id, + reward_policy FROM tasks WHERE id = $1 AND user_id = $2 AND deleted = FALSE `, taskID, userID).Scan( - &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, + &task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &rewardPolicy, ) log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr) @@ -7305,6 +7482,18 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Creating task without wishlist_id") } + // Подготовка reward_policy: если задача связана с желанием и политика не указана, используем "personal" по умолчанию + var rewardPolicyValue interface{} + if req.WishlistID != nil { + if req.RewardPolicy != nil && (*req.RewardPolicy == "personal" || *req.RewardPolicy == "general") { + rewardPolicyValue = *req.RewardPolicy + } else { + rewardPolicyValue = "personal" // Значение по умолчанию для задач, связанных с желаниями + } + } else { + rewardPolicyValue = nil // NULL для задач, не связанных с желаниями + } + // Используем условный SQL для обработки NULL значений var insertSQL string var insertArgs []interface{} @@ -7312,36 +7501,36 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) { // Для repetition_period выставляем сегодняшнюю дату now := time.Now() insertSQL = ` - INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id) - VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE, $7) + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy) + VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE, $7, $8) RETURNING id ` - insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now, wishlistIDValue} + insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now, wishlistIDValue, rewardPolicyValue} } else if repetitionDate.Valid { // Вычисляем next_show_at для задачи с repetition_date nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now()) if nextShowAt != nil { insertSQL = ` - INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id) - VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE, $7) + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy) + VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE, $7, $8) RETURNING id ` - insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, wishlistIDValue} + insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, wishlistIDValue, rewardPolicyValue} } else { insertSQL = ` - INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id) - VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE, $6) + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy) + VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE, $6, $7) RETURNING id ` - insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, wishlistIDValue} + insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, wishlistIDValue, rewardPolicyValue} } } else { insertSQL = ` - INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id) - VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE, $5) + INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy) + VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE, $5, $6) RETURNING id ` - insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, wishlistIDValue} + insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, wishlistIDValue, rewardPolicyValue} } err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID) @@ -7671,6 +7860,25 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { log.Printf("Updating task %d with repetition_date: %s", taskID, repetitionDate.String) } + // Подготовка reward_policy: если задача связана с желанием и политика не указана, используем "personal" по умолчанию + var rewardPolicyValue interface{} + if newWishlistID != nil { + if req.RewardPolicy != nil && (*req.RewardPolicy == "personal" || *req.RewardPolicy == "general") { + rewardPolicyValue = *req.RewardPolicy + } else { + // Если задача уже была привязана, сохраняем текущую политику, иначе используем "personal" + var currentRewardPolicy sql.NullString + err = a.DB.QueryRow("SELECT reward_policy FROM tasks WHERE id = $1", taskID).Scan(¤tRewardPolicy) + if err == nil && currentRewardPolicy.Valid { + rewardPolicyValue = currentRewardPolicy.String + } else { + rewardPolicyValue = "personal" // Значение по умолчанию для задач, связанных с желаниями + } + } + } else { + rewardPolicyValue = nil // NULL для задач, не связанных с желаниями + } + // Используем условный SQL для обработки NULL значений var updateSQL string var updateArgs []interface{} @@ -7679,35 +7887,35 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) { now := time.Now() updateSQL = ` UPDATE tasks - SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, next_show_at = $5, wishlist_id = $6 - WHERE id = $7 + SET name = $1, reward_message = $2, progression_base = $3, repetition_period = $4::INTERVAL, repetition_date = NULL, next_show_at = $5, wishlist_id = $6, reward_policy = $7 + WHERE id = $8 ` - updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, newWishlistID, taskID} + updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, newWishlistID, rewardPolicyValue, taskID} } else if repetitionDate.Valid { // Вычисляем next_show_at для задачи с repetition_date nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now()) if nextShowAt != nil { updateSQL = ` UPDATE tasks - SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, next_show_at = $5, wishlist_id = $6 - WHERE id = $7 + SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, next_show_at = $5, wishlist_id = $6, reward_policy = $7 + WHERE id = $8 ` - updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, newWishlistID, taskID} + updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, newWishlistID, rewardPolicyValue, taskID} } else { updateSQL = ` UPDATE tasks - SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, wishlist_id = $5 - WHERE id = $6 + SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, wishlist_id = $5, reward_policy = $6 + WHERE id = $7 ` - updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, newWishlistID, taskID} + updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, newWishlistID, rewardPolicyValue, taskID} } } else { updateSQL = ` UPDATE tasks - SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = NULL, next_show_at = NULL, wishlist_id = $4 - WHERE id = $5 + SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = NULL, next_show_at = NULL, wishlist_id = $4, reward_policy = $5 + WHERE id = $6 ` - updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, newWishlistID, taskID} + updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, newWishlistID, rewardPolicyValue, taskID} } _, err = tx.Exec(updateSQL, updateArgs...) @@ -8504,20 +8712,22 @@ func (a *App) completeTaskHandler(w http.ResponseWriter, r *http.Request) { } } - // Если задача связана с желанием, завершаем желание + // Если задача связана с желанием, завершаем желание и обрабатываем политику награждения // Используем wishlistID из начала функции (уже объявлена) if wishlistID.Valid { // Завершаем желание _, completeErr := a.DB.Exec(` UPDATE wishlist_items - SET completed = TRUE - WHERE id = $1 AND user_id = $2 AND completed = FALSE - `, wishlistID.Int64, userID) + SET completed = TRUE, updated_at = NOW() + WHERE id = $1 AND completed = FALSE + `, wishlistID.Int64) if completeErr != nil { log.Printf("Error completing wishlist item %d: %v", wishlistID.Int64, completeErr) // Не возвращаем ошибку, задача уже выполнена } else { log.Printf("Wishlist item %d completed automatically after task %d completion", wishlistID.Int64, taskID) + // Обрабатываем политику награждения для всех задач, связанных с этим желанием + a.processWishlistRewardPolicy(int(wishlistID.Int64), userID) } } @@ -9372,15 +9582,15 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) } // Загружаем связанную задачу, если есть - var linkedTaskID, linkedTaskCompleted sql.NullInt64 + 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 + 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 LIMIT 1 - `, item.ID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt) + `, item.ID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) if linkedTaskErr == nil && linkedTaskID.Valid { linkedTask := &LinkedTask{ @@ -9392,6 +9602,10 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool) 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) @@ -9791,15 +10005,15 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { } // Загружаем связанную задачу, если есть - var linkedTaskID, linkedTaskCompleted sql.NullInt64 + 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 + 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 LIMIT 1 - `, itemID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt) + `, itemID).Scan(&linkedTaskID, &linkedTaskName, &linkedTaskCompleted, &linkedTaskNextShowAt, &linkedTaskUserID) if err == nil && linkedTaskID.Valid { linkedTask := &LinkedTask{ @@ -9811,6 +10025,10 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) { nextShowAtStr := linkedTaskNextShowAt.Time.Format(time.RFC3339) linkedTask.NextShowAt = &nextShowAtStr } + if linkedTaskUserID.Valid { + userIDVal := int(linkedTaskUserID.Int64) + linkedTask.UserID = &userIDVal + } item.LinkedTask = linkedTask } else if err != sql.ErrNoRows { log.Printf("Error loading linked task for wishlist %d: %v", itemID, err) @@ -10151,6 +10369,9 @@ func (a *App) completeWishlistHandler(w http.ResponseWriter, r *http.Request) { return } + // Обрабатываем политику награждения для всех задач, связанных с этим желанием + a.processWishlistRewardPolicy(itemID, userID) + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "success": true, @@ -10158,6 +10379,76 @@ 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) + if err != nil { + log.Printf("Error querying tasks for wishlist item %d: %v", wishlistItemID, err) + return + } + defer rows.Close() + + for rows.Next() { + var taskID, taskUserID int + var rewardPolicy sql.NullString + err := rows.Scan(&taskID, &taskUserID, &rewardPolicy) + if err != nil { + log.Printf("Error scanning task: %v", err) + continue + } + + policy := "personal" // Значение по умолчанию + if rewardPolicy.Valid { + policy = rewardPolicy.String + } + + 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(` + 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) + } + } + } else if policy == "general" { + // Общая политика: задача выполняется независимо от того, кто завершил желание + _, 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 (general policy)", taskID, wishlistItemID) + } + } + } +} + // uncompleteWishlistHandler снимает отметку завершения func (a *App) uncompleteWishlistHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { @@ -10455,6 +10746,1344 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(createdItem) } +// ============================================ +// Wishlist Boards handlers +// ============================================ + +// generateInviteToken генерирует уникальный токен для приглашения +func generateInviteToken() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// getBoardsHandler возвращает список досок пользователя (свои + присоединённые) +func (a *App) getBoardsHandler(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 + } + + boards := []WishlistBoard{} + + // Получаем свои доски + доски где пользователь участник + rows, err := a.DB.Query(` + SELECT DISTINCT + wb.id, + wb.owner_id, + COALESCE(u.name, u.email) as owner_name, + wb.name, + wb.invite_enabled, + wb.invite_token, + wb.created_at, + (SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count, + (wb.owner_id = $1) as is_owner + FROM wishlist_boards wb + JOIN users u ON wb.owner_id = u.id + LEFT JOIN wishlist_board_members wbm ON wb.id = wbm.board_id + WHERE wb.deleted = FALSE + AND (wb.owner_id = $1 OR wbm.user_id = $1) + ORDER BY is_owner DESC, wb.created_at DESC + `, userID) + if err != nil { + log.Printf("Error getting boards: %v", err) + sendErrorWithCORS(w, "Error getting boards", http.StatusInternalServerError) + return + } + defer rows.Close() + + baseURL := getEnv("WEBHOOK_BASE_URL", "") + + for rows.Next() { + var board WishlistBoard + var inviteToken sql.NullString + err := rows.Scan( + &board.ID, + &board.OwnerID, + &board.OwnerName, + &board.Name, + &board.InviteEnabled, + &inviteToken, + &board.CreatedAt, + &board.MemberCount, + &board.IsOwner, + ) + if err != nil { + log.Printf("Error scanning board: %v", err) + continue + } + + // Invite token и URL только для владельца + if board.IsOwner && inviteToken.Valid { + board.InviteToken = &inviteToken.String + if baseURL != "" { + url := baseURL + "/invite/" + inviteToken.String + board.InviteURL = &url + } + } + + boards = append(boards, board) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(boards) +} + +// createBoardHandler создаёт новую доску +func (a *App) createBoardHandler(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 + } + + var req BoardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Name) == "" { + sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) + return + } + + var boardID int + err := a.DB.QueryRow(` + INSERT INTO wishlist_boards (owner_id, name) + VALUES ($1, $2) + RETURNING id + `, userID, strings.TrimSpace(req.Name)).Scan(&boardID) + + if err != nil { + log.Printf("Error creating board: %v", err) + sendErrorWithCORS(w, "Error creating board", http.StatusInternalServerError) + return + } + + // Возвращаем созданную доску + board := WishlistBoard{ + ID: boardID, + OwnerID: userID, + Name: strings.TrimSpace(req.Name), + InviteEnabled: false, + MemberCount: 0, + IsOwner: true, + CreatedAt: time.Now(), + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(board) +} + +// getBoardHandler возвращает детали доски +func (a *App) getBoardHandler(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) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + var board WishlistBoard + var inviteToken sql.NullString + + err = a.DB.QueryRow(` + SELECT + wb.id, + wb.owner_id, + COALESCE(u.name, u.email) as owner_name, + wb.name, + wb.invite_enabled, + wb.invite_token, + wb.created_at, + (SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count + FROM wishlist_boards wb + JOIN users u ON wb.owner_id = u.id + WHERE wb.id = $1 AND wb.deleted = FALSE + `, boardID).Scan( + &board.ID, + &board.OwnerID, + &board.OwnerName, + &board.Name, + &board.InviteEnabled, + &inviteToken, + &board.CreatedAt, + &board.MemberCount, + ) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting board: %v", err) + sendErrorWithCORS(w, "Error getting board", http.StatusInternalServerError) + return + } + + board.IsOwner = board.OwnerID == userID + + // Проверяем доступ (владелец или участник) + if !board.IsOwner { + var isMember bool + a.DB.QueryRow(` + SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2) + `, boardID, userID).Scan(&isMember) + + if !isMember { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + } + + // Invite token и URL только для владельца + if board.IsOwner && inviteToken.Valid { + board.InviteToken = &inviteToken.String + baseURL := getEnv("WEBHOOK_BASE_URL", "") + if baseURL != "" { + url := baseURL + "/invite/" + inviteToken.String + board.InviteURL = &url + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(board) +} + +// updateBoardHandler обновляет доску (только владелец) +func (a *App) updateBoardHandler(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) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем что пользователь - владелец + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can update board", http.StatusForbidden) + return + } + + var req BoardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Обновляем поля + if strings.TrimSpace(req.Name) != "" { + _, err = a.DB.Exec(`UPDATE wishlist_boards SET name = $1, updated_at = NOW() WHERE id = $2`, + strings.TrimSpace(req.Name), boardID) + if err != nil { + log.Printf("Error updating board name: %v", err) + } + } + + if req.InviteEnabled != nil { + // Если включаем приглашения и нет токена - генерируем + if *req.InviteEnabled { + var currentToken sql.NullString + a.DB.QueryRow(`SELECT invite_token FROM wishlist_boards WHERE id = $1`, boardID).Scan(¤tToken) + + if !currentToken.Valid || currentToken.String == "" { + token := generateInviteToken() + _, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_enabled = TRUE, invite_token = $1, updated_at = NOW() WHERE id = $2`, + token, boardID) + } else { + _, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_enabled = TRUE, updated_at = NOW() WHERE id = $1`, boardID) + } + } else { + _, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_enabled = FALSE, updated_at = NOW() WHERE id = $1`, boardID) + } + if err != nil { + log.Printf("Error updating board invite_enabled: %v", err) + } + } + + // Возвращаем обновлённую доску + var board WishlistBoard + var inviteToken sql.NullString + + a.DB.QueryRow(` + SELECT + wb.id, wb.owner_id, COALESCE(u.name, u.email), wb.name, wb.invite_enabled, wb.invite_token, wb.created_at, + (SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) + FROM wishlist_boards wb + JOIN users u ON wb.owner_id = u.id + WHERE wb.id = $1 + `, boardID).Scan(&board.ID, &board.OwnerID, &board.OwnerName, &board.Name, &board.InviteEnabled, &inviteToken, &board.CreatedAt, &board.MemberCount) + + board.IsOwner = true + if inviteToken.Valid { + board.InviteToken = &inviteToken.String + baseURL := getEnv("WEBHOOK_BASE_URL", "") + if baseURL != "" { + url := baseURL + "/invite/" + inviteToken.String + board.InviteURL = &url + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(board) +} + +// deleteBoardHandler удаляет доску (только владелец) +func (a *App) deleteBoardHandler(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) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем что пользователь - владелец + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can delete board", http.StatusForbidden) + return + } + + // Soft delete доски и всех её желаний + _, err = a.DB.Exec(`UPDATE wishlist_boards SET deleted = TRUE, updated_at = NOW() WHERE id = $1`, boardID) + if err != nil { + log.Printf("Error deleting board: %v", err) + sendErrorWithCORS(w, "Error deleting board", http.StatusInternalServerError) + return + } + + // Soft delete всех желаний на доске + _, err = a.DB.Exec(`UPDATE wishlist_items SET deleted = TRUE, updated_at = NOW() WHERE board_id = $1`, boardID) + if err != nil { + log.Printf("Error deleting board items: %v", err) + } + + w.WriteHeader(http.StatusNoContent) +} + +// regenerateBoardInviteHandler перегенерирует invite token +func (a *App) regenerateBoardInviteHandler(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) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем что пользователь - владелец + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can regenerate invite", http.StatusForbidden) + return + } + + token := generateInviteToken() + _, err = a.DB.Exec(`UPDATE wishlist_boards SET invite_token = $1, invite_enabled = TRUE, updated_at = NOW() WHERE id = $2`, + token, boardID) + if err != nil { + log.Printf("Error regenerating invite token: %v", err) + sendErrorWithCORS(w, "Error regenerating invite", http.StatusInternalServerError) + return + } + + baseURL := getEnv("WEBHOOK_BASE_URL", "") + inviteURL := "" + if baseURL != "" { + inviteURL = baseURL + "/invite/" + token + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "invite_token": token, + "invite_url": inviteURL, + }) +} + +// getBoardMembersHandler возвращает список участников доски +func (a *App) getBoardMembersHandler(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) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем что пользователь - владелец + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can view members", http.StatusForbidden) + return + } + + members := []BoardMember{} + rows, err := a.DB.Query(` + SELECT wbm.id, wbm.user_id, COALESCE(u.name, '') as name, u.email, wbm.joined_at + FROM wishlist_board_members wbm + JOIN users u ON wbm.user_id = u.id + WHERE wbm.board_id = $1 + ORDER BY wbm.joined_at DESC + `, boardID) + if err != nil { + log.Printf("Error getting members: %v", err) + sendErrorWithCORS(w, "Error getting members", http.StatusInternalServerError) + return + } + defer rows.Close() + + for rows.Next() { + var member BoardMember + err := rows.Scan(&member.ID, &member.UserID, &member.Name, &member.Email, &member.JoinedAt) + if err != nil { + log.Printf("Error scanning member: %v", err) + continue + } + members = append(members, member) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(members) +} + +// removeBoardMemberHandler удаляет участника из доски +func (a *App) removeBoardMemberHandler(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) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + memberUserID, err := strconv.Atoi(vars["userId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid user ID", http.StatusBadRequest) + return + } + + // Проверяем что пользователь - владелец + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID != userID { + sendErrorWithCORS(w, "Only owner can remove members", http.StatusForbidden) + return + } + + _, err = a.DB.Exec(`DELETE FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2`, boardID, memberUserID) + if err != nil { + log.Printf("Error removing member: %v", err) + sendErrorWithCORS(w, "Error removing member", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// leaveBoardHandler позволяет участнику выйти из доски +func (a *App) leaveBoardHandler(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) + boardID, err := strconv.Atoi(vars["id"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем что пользователь НЕ владелец + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + if ownerID == userID { + sendErrorWithCORS(w, "Owner cannot leave board, delete it instead", http.StatusBadRequest) + return + } + + _, err = a.DB.Exec(`DELETE FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2`, boardID, userID) + if err != nil { + log.Printf("Error leaving board: %v", err) + sendErrorWithCORS(w, "Error leaving board", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// getBoardInviteInfoHandler возвращает информацию о доске по invite token +func (a *App) getBoardInviteInfoHandler(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 info BoardInviteInfo + var ownerName string + err := a.DB.QueryRow(` + SELECT + wb.id, + wb.name, + COALESCE(u.name, u.email) as owner_name, + (SELECT COUNT(*) FROM wishlist_board_members wbm WHERE wbm.board_id = wb.id) as member_count + FROM wishlist_boards wb + JOIN users u ON wb.owner_id = u.id + WHERE wb.invite_token = $1 AND wb.invite_enabled = TRUE AND wb.deleted = FALSE + `, token).Scan(&info.BoardID, &info.Name, &ownerName, &info.MemberCount) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting invite info: %v", err) + sendErrorWithCORS(w, "Error getting invite info", http.StatusInternalServerError) + return + } + + info.OwnerName = ownerName + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(info) +} + +// joinBoardHandler присоединяет пользователя к доске по invite token +func (a *App) joinBoardHandler(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) + token := vars["token"] + + // Получаем доску по токену + var boardID, ownerID int + var boardName, ownerName string + err := a.DB.QueryRow(` + SELECT wb.id, wb.owner_id, wb.name, COALESCE(u.name, u.email) + FROM wishlist_boards wb + JOIN users u ON wb.owner_id = u.id + WHERE wb.invite_token = $1 AND wb.invite_enabled = TRUE AND wb.deleted = FALSE + `, token).Scan(&boardID, &ownerID, &boardName, &ownerName) + + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Invalid or expired invite link", http.StatusNotFound) + return + } + if err != nil { + log.Printf("Error getting board by token: %v", err) + sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError) + return + } + + // Проверяем что пользователь не владелец + if ownerID == userID { + sendErrorWithCORS(w, "You are the owner of this board", http.StatusBadRequest) + return + } + + // Проверяем что пользователь ещё не участник + var exists bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&exists) + if exists { + sendErrorWithCORS(w, "You are already a member of this board", http.StatusBadRequest) + return + } + + // Добавляем пользователя как участника + _, err = a.DB.Exec(`INSERT INTO wishlist_board_members (board_id, user_id) VALUES ($1, $2)`, boardID, userID) + if err != nil { + log.Printf("Error joining board: %v", err) + sendErrorWithCORS(w, "Error joining board", http.StatusInternalServerError) + return + } + + // Получаем количество участников + var memberCount int + a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_board_members WHERE board_id = $1`, boardID).Scan(&memberCount) + + board := WishlistBoard{ + ID: boardID, + OwnerID: ownerID, + OwnerName: ownerName, + Name: boardName, + InviteEnabled: true, + MemberCount: memberCount, + IsOwner: false, + } + + response := JoinBoardResponse{ + Board: board, + Message: "Вы успешно присоединились к доске!", + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) +} + +// getBoardItemsHandler возвращает желания на доске +func (a *App) getBoardItemsHandler(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) + boardID, err := strconv.Atoi(vars["boardId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем доступ к доске (владелец или участник) + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + + hasAccess := ownerID == userID + if !hasAccess { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + hasAccess = isMember + } + + if !hasAccess { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + + // Получаем желания на доске (используем существующую логику, но фильтруем по board_id) + items, err := a.getWishlistItemsByBoard(boardID, userID) + if err != nil { + log.Printf("Error getting board items: %v", err) + sendErrorWithCORS(w, "Error getting items", http.StatusInternalServerError) + return + } + + // Разделяем на unlocked/locked + unlocked := []WishlistItem{} + locked := []WishlistItem{} + for _, item := range items { + if item.Unlocked { + unlocked = append(unlocked, item) + } else { + locked = append(locked, item) + } + } + + // Считаем завершённые + var completedCount int + a.DB.QueryRow(`SELECT COUNT(*) FROM wishlist_items WHERE board_id = $1 AND completed = TRUE AND deleted = FALSE`, + boardID).Scan(&completedCount) + + response := WishlistResponse{ + Unlocked: unlocked, + Locked: locked, + CompletedCount: completedCount, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// getBoardCompletedHandler возвращает завершённые желания на доске +func (a *App) getBoardCompletedHandler(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) + boardID, err := strconv.Atoi(vars["boardId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем доступ к доске (владелец или участник) + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + + hasAccess := ownerID == userID + if !hasAccess { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + hasAccess = isMember + } + + if !hasAccess { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + + // Получаем завершённые желания на доске (отдельный запрос, так как getWishlistItemsByBoard исключает завершённые) + query := ` + SELECT + wi.id, + wi.name, + wi.price, + wi.image_path, + wi.link, + wi.completed, + wc.id AS condition_id, + wc.display_order, + wc.task_condition_id, + wc.score_condition_id, + wc.user_id, + tc.task_id, + t.name AS task_name, + sc.project_id, + p.name AS project_name, + sc.required_points, + sc.start_date, + COALESCE(u.name, u.email) AS user_name + FROM wishlist_items wi + LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id + 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 users u ON wc.user_id = u.id + WHERE wi.board_id = $1 + AND wi.deleted = FALSE + AND wi.completed = TRUE + ORDER BY wi.id, wc.display_order, wc.id + ` + + rows, err := a.DB.Query(query, boardID) + if err != nil { + log.Printf("Error executing query for board completed items (boardID=%d): %v", boardID, err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting completed items: %v", err), http.StatusInternalServerError) + return + } + defer rows.Close() + + itemsMap := make(map[int]*WishlistItem) + + for rows.Next() { + var itemID int + var name string + var price sql.NullFloat64 + var imagePath sql.NullString + var link sql.NullString + var completed bool + var conditionID sql.NullInt64 + var displayOrder sql.NullInt64 + var taskConditionID sql.NullInt64 + var scoreConditionID sql.NullInt64 + var userIDCond sql.NullInt64 + var taskID sql.NullInt64 + var taskName sql.NullString + var projectID sql.NullInt64 + var projectName sql.NullString + var requiredPoints sql.NullFloat64 + var startDate sql.NullTime + var userName sql.NullString + + err := rows.Scan( + &itemID, &name, &price, &imagePath, &link, &completed, + &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &userIDCond, + &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, &userName, + ) + if err != nil { + log.Printf("Error scanning completed wishlist item: %v", err) + continue + } + + item, exists := itemsMap[itemID] + if !exists { + item = &WishlistItem{ + ID: itemID, + Name: name, + Completed: completed, + UnlockConditions: []UnlockConditionDisplay{}, + } + if price.Valid { + item.Price = &price.Float64 + } + if imagePath.Valid && imagePath.String != "" { + url := imagePath.String + if !strings.HasPrefix(url, "http") { + url = url + "?t=" + strconv.FormatInt(time.Now().Unix(), 10) + } + item.ImageURL = &url + } + if link.Valid { + item.Link = &link.String + } + itemsMap[itemID] = item + } + + if conditionID.Valid { + condition := UnlockConditionDisplay{ + ID: int(conditionID.Int64), + DisplayOrder: int(displayOrder.Int64), + } + + if taskConditionID.Valid { + condition.Type = "task_completion" + if taskID.Valid { + taskIDVal := int(taskID.Int64) + condition.TaskID = &taskIDVal + if taskName.Valid { + condition.TaskName = &taskName.String + } + } + } else if scoreConditionID.Valid { + condition.Type = "project_points" + if projectID.Valid { + projectIDVal := int(projectID.Int64) + condition.ProjectID = &projectIDVal + if projectName.Valid { + condition.ProjectName = &projectName.String + } + if requiredPoints.Valid { + condition.RequiredPoints = &requiredPoints.Float64 + } + if startDate.Valid { + dateStr := startDate.Time.Format("2006-01-02") + condition.StartDate = &dateStr + } + } + } + + if userIDCond.Valid { + userIDVal := int(userIDCond.Int64) + condition.UserID = &userIDVal + if userName.Valid { + condition.UserName = &userName.String + } + } + + item.UnlockConditions = append(item.UnlockConditions, condition) + } + } + + if err := rows.Err(); err != nil { + log.Printf("Error iterating rows for board completed items (boardID=%d): %v", boardID, err) + sendErrorWithCORS(w, fmt.Sprintf("Error getting completed items: %v", err), http.StatusInternalServerError) + return + } + + // Преобразуем map в slice + completed := make([]WishlistItem, 0, len(itemsMap)) + for _, item := range itemsMap { + completed = append(completed, *item) + } + + // Сортируем по цене (дорогие → дешёвые) + sort.Slice(completed, func(i, j int) bool { + priceI := 0.0 + priceJ := 0.0 + if completed[i].Price != nil { + priceI = *completed[i].Price + } + if completed[j].Price != nil { + priceJ = *completed[j].Price + } + return priceI > priceJ + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(completed) +} + +// getWishlistItemsByBoard загружает желания конкретной доски +func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem, error) { + query := ` + SELECT + wi.id, + wi.name, + wi.price, + wi.image_path, + wi.link, + wi.completed, + wc.id AS condition_id, + wc.display_order, + wc.task_condition_id, + wc.score_condition_id, + tc.task_id, + t.name AS task_name, + sc.project_id, + p.name AS project_name, + sc.required_points, + sc.start_date + FROM wishlist_items wi + LEFT JOIN wishlist_conditions wc ON wi.id = wc.wishlist_item_id + 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 + WHERE wi.board_id = $1 + AND wi.deleted = FALSE + AND wi.completed = FALSE + ORDER BY wi.id, wc.display_order, wc.id + ` + + rows, err := a.DB.Query(query, boardID) + if err != nil { + return nil, err + } + defer rows.Close() + + itemsMap := make(map[int]*WishlistItem) + + for rows.Next() { + var itemID int + var name string + var price sql.NullFloat64 + var imagePath sql.NullString + var link sql.NullString + var completed bool + var conditionID sql.NullInt64 + var displayOrder sql.NullInt64 + var taskConditionID sql.NullInt64 + var scoreConditionID sql.NullInt64 + var taskID sql.NullInt64 + var taskName sql.NullString + var projectID sql.NullInt64 + var projectName sql.NullString + var requiredPoints sql.NullFloat64 + var startDate sql.NullTime + + err := rows.Scan( + &itemID, &name, &price, &imagePath, &link, &completed, + &conditionID, &displayOrder, &taskConditionID, &scoreConditionID, + &taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate, + ) + if err != nil { + log.Printf("Error scanning wishlist item: %v", err) + continue + } + + item, exists := itemsMap[itemID] + if !exists { + item = &WishlistItem{ + ID: itemID, + Name: name, + Completed: completed, + UnlockConditions: []UnlockConditionDisplay{}, + } + if price.Valid { + item.Price = &price.Float64 + } + if imagePath.Valid && imagePath.String != "" { + url := imagePath.String + if !strings.HasPrefix(url, "http") { + url = url + "?t=" + strconv.FormatInt(time.Now().Unix(), 10) + } + item.ImageURL = &url + } + if link.Valid { + item.Link = &link.String + } + itemsMap[itemID] = item + } + + if conditionID.Valid { + condition := UnlockConditionDisplay{ + ID: int(conditionID.Int64), + DisplayOrder: int(displayOrder.Int64), + } + + if taskConditionID.Valid { + condition.Type = "task_completion" + if taskName.Valid { + condition.TaskName = &taskName.String + } + // Проверяем выполнена ли задача + if taskID.Valid { + var taskCompleted int + a.DB.QueryRow(`SELECT completed FROM tasks WHERE id = $1`, taskID.Int64).Scan(&taskCompleted) + isCompleted := taskCompleted > 0 + condition.TaskCompleted = &isCompleted + } + } else if scoreConditionID.Valid { + condition.Type = "project_points" + if projectName.Valid { + condition.ProjectName = &projectName.String + } + if requiredPoints.Valid { + condition.RequiredPoints = &requiredPoints.Float64 + } + if startDate.Valid { + dateStr := startDate.Time.Format("2006-01-02") + condition.StartDate = &dateStr + } + // Считаем текущие баллы + if projectID.Valid { + points, _ := a.calculateProjectPointsFromDate(int(projectID.Int64), startDate, userID) + condition.CurrentPoints = &points + } + } + + item.UnlockConditions = append(item.UnlockConditions, condition) + } + } + + // Преобразуем map в slice и определяем unlocked + items := make([]WishlistItem, 0, len(itemsMap)) + for _, item := range itemsMap { + // Проверяем все условия + item.Unlocked = true + if len(item.UnlockConditions) > 0 { + for _, cond := range item.UnlockConditions { + if cond.Type == "task_completion" { + if cond.TaskCompleted == nil || !*cond.TaskCompleted { + item.Unlocked = false + break + } + } else if cond.Type == "project_points" { + if cond.CurrentPoints == nil || cond.RequiredPoints == nil || *cond.CurrentPoints < *cond.RequiredPoints { + item.Unlocked = false + break + } + } + } + } + items = append(items, *item) + } + + // Сортируем по ID для стабильного порядка + sort.Slice(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) + + return items, nil +} + +// createBoardItemHandler создаёт желание на доске +func (a *App) createBoardItemHandler(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) + boardID, err := strconv.Atoi(vars["boardId"]) + if err != nil { + sendErrorWithCORS(w, "Invalid board ID", http.StatusBadRequest) + return + } + + // Проверяем доступ к доске + var ownerID int + err = a.DB.QueryRow(`SELECT owner_id FROM wishlist_boards WHERE id = $1 AND deleted = FALSE`, boardID).Scan(&ownerID) + if err == sql.ErrNoRows { + sendErrorWithCORS(w, "Board not found", http.StatusNotFound) + return + } + + hasAccess := ownerID == userID + if !hasAccess { + var isMember bool + a.DB.QueryRow(`SELECT EXISTS(SELECT 1 FROM wishlist_board_members WHERE board_id = $1 AND user_id = $2)`, + boardID, userID).Scan(&isMember) + hasAccess = isMember + } + + if !hasAccess { + sendErrorWithCORS(w, "Access denied", http.StatusForbidden) + return + } + + var req WishlistRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if strings.TrimSpace(req.Name) == "" { + sendErrorWithCORS(w, "Name is required", http.StatusBadRequest) + return + } + + tx, err := a.DB.Begin() + if err != nil { + log.Printf("Error starting transaction: %v", err) + sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + var itemID int + err = tx.QueryRow(` + 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 + `, ownerID, boardID, userID, strings.TrimSpace(req.Name), req.Price, req.Link).Scan(&itemID) + + if err != nil { + log.Printf("Error creating board item: %v", err) + sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError) + return + } + + // Сохраняем условия с user_id текущего пользователя + if len(req.UnlockConditions) > 0 { + err = a.saveWishlistConditionsWithUserID(tx, itemID, userID, req.UnlockConditions) + if err != nil { + log.Printf("Error saving conditions: %v", err) + sendErrorWithCORS(w, "Error saving conditions", http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + sendErrorWithCORS(w, "Error creating item", http.StatusInternalServerError) + return + } + + // Возвращаем созданное желание + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]int{"id": itemID}) +} + +// saveWishlistConditionsWithUserID сохраняет условия с указанием user_id +func (a *App) saveWishlistConditionsWithUserID(tx *sql.Tx, wishlistItemID int, userID int, conditions []UnlockConditionRequest) error { + for i, cond := range conditions { + displayOrder := i + if cond.DisplayOrder != nil { + displayOrder = *cond.DisplayOrder + } + + switch cond.Type { + case "task_completion": + if cond.TaskID == nil { + continue + } + // Создаём task_condition + var taskConditionID int + err := tx.QueryRow(` + INSERT INTO task_conditions (task_id) + VALUES ($1) + ON CONFLICT (task_id) DO UPDATE SET task_id = EXCLUDED.task_id + RETURNING id + `, *cond.TaskID).Scan(&taskConditionID) + if err != nil { + return fmt.Errorf("error creating task condition: %w", err) + } + // Связываем с wishlist_item + _, err = tx.Exec(` + INSERT INTO wishlist_conditions (wishlist_item_id, user_id, task_condition_id, display_order) + VALUES ($1, $2, $3, $4) + `, wishlistItemID, userID, taskConditionID, displayOrder) + if err != nil { + return fmt.Errorf("error linking task condition: %w", err) + } + + case "project_points": + if cond.ProjectID == nil || cond.RequiredPoints == nil { + continue + } + // Создаём score_condition + var scoreConditionID int + var startDateVal interface{} = nil + if cond.StartDate != nil && *cond.StartDate != "" { + startDateVal = *cond.StartDate + } + err := tx.QueryRow(` + INSERT INTO score_conditions (project_id, required_points, start_date) + VALUES ($1, $2, $3) + ON CONFLICT (project_id, required_points, start_date) DO UPDATE SET required_points = EXCLUDED.required_points + RETURNING id + `, *cond.ProjectID, *cond.RequiredPoints, startDateVal).Scan(&scoreConditionID) + if err != nil { + return fmt.Errorf("error creating score condition: %w", err) + } + // Связываем с wishlist_item + _, err = tx.Exec(` + INSERT INTO wishlist_conditions (wishlist_item_id, user_id, score_condition_id, display_order) + VALUES ($1, $2, $3, $4) + `, wishlistItemID, userID, scoreConditionID, displayOrder) + if err != nil { + return fmt.Errorf("error linking score condition: %w", err) + } + } + } + return nil +} + // LinkMetadataResponse структура ответа с метаданными ссылки type LinkMetadataResponse struct { Title string `json:"title,omitempty"` diff --git a/play-life-backend/migrations/023_add_wishlist_boards.sql b/play-life-backend/migrations/023_add_wishlist_boards.sql new file mode 100644 index 0000000..b690cdc --- /dev/null +++ b/play-life-backend/migrations/023_add_wishlist_boards.sql @@ -0,0 +1,116 @@ +-- Migration: Add wishlist boards for multi-user collaboration +-- Each user can have multiple boards, share them via invite links, +-- and collaborate with other users on shared wishes + +-- ============================================ +-- Table: wishlist_boards (доски желаний) +-- ============================================ +CREATE TABLE IF NOT EXISTS wishlist_boards ( + id SERIAL PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + + -- Настройки доступа по ссылке + invite_token VARCHAR(64) UNIQUE, + invite_enabled BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_wishlist_boards_owner_id ON wishlist_boards(owner_id); +CREATE INDEX IF NOT EXISTS idx_wishlist_boards_invite_token ON wishlist_boards(invite_token) + WHERE invite_token IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_wishlist_boards_owner_deleted ON wishlist_boards(owner_id, deleted); + +-- ============================================ +-- Table: wishlist_board_members (участники доски) +-- ============================================ +CREATE TABLE IF NOT EXISTS wishlist_board_members ( + id SERIAL PRIMARY KEY, + board_id INTEGER NOT NULL REFERENCES wishlist_boards(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT unique_board_member UNIQUE (board_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_board_members_board_id ON wishlist_board_members(board_id); +CREATE INDEX IF NOT EXISTS idx_board_members_user_id ON wishlist_board_members(user_id); + +-- ============================================ +-- Modify: wishlist_items - добавляем board_id и author_id +-- ============================================ +ALTER TABLE wishlist_items +ADD COLUMN IF NOT EXISTS board_id INTEGER REFERENCES wishlist_boards(id) ON DELETE CASCADE; + +ALTER TABLE wishlist_items +ADD COLUMN IF NOT EXISTS author_id INTEGER REFERENCES users(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_wishlist_items_board_id ON wishlist_items(board_id); +CREATE INDEX IF NOT EXISTS idx_wishlist_items_author_id ON wishlist_items(author_id); + +-- ============================================ +-- Modify: wishlist_conditions - добавляем user_id для персональных целей +-- ============================================ +ALTER TABLE wishlist_conditions +ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_wishlist_conditions_user_id ON wishlist_conditions(user_id); + +-- ============================================ +-- Modify: tasks - добавляем политику награждения для wishlist задач +-- ============================================ +ALTER TABLE tasks +ADD COLUMN IF NOT EXISTS reward_policy VARCHAR(20) DEFAULT 'personal'; + +COMMENT ON COLUMN tasks.reward_policy IS + 'For wishlist tasks: personal = only if user completes, shared = anyone completes'; + +-- ============================================ +-- Миграция данных: Этап 1 - создаём персональные доски +-- ============================================ +-- Создаём доску "Мои желания" для каждого пользователя с желаниями +INSERT INTO wishlist_boards (owner_id, name) +SELECT DISTINCT user_id, 'Мои желания' +FROM wishlist_items +WHERE user_id IS NOT NULL + AND deleted = FALSE + AND NOT EXISTS ( + SELECT 1 FROM wishlist_boards wb + WHERE wb.owner_id = wishlist_items.user_id AND wb.name = 'Мои желания' + ); + +-- ============================================ +-- Миграция данных: Этап 2 - привязываем желания к доскам +-- ============================================ +UPDATE wishlist_items wi +SET + board_id = wb.id, + author_id = COALESCE(wi.author_id, wi.user_id) +FROM wishlist_boards wb +WHERE wi.board_id IS NULL + AND wi.user_id = wb.owner_id + AND wb.name = 'Мои желания'; + +-- ============================================ +-- Миграция данных: Этап 3 - заполняем user_id в условиях +-- ============================================ +UPDATE wishlist_conditions wc +SET user_id = wi.user_id +FROM wishlist_items wi +WHERE wc.wishlist_item_id = wi.id + AND wc.user_id IS NULL; + +-- ============================================ +-- Comments +-- ============================================ +COMMENT ON TABLE wishlist_boards IS 'Wishlist boards for organizing and sharing wishes'; +COMMENT ON COLUMN wishlist_boards.invite_token IS 'Token for invite link, NULL = disabled'; +COMMENT ON COLUMN wishlist_boards.invite_enabled IS 'Whether invite link is active'; +COMMENT ON TABLE wishlist_board_members IS 'Users who joined boards via invite link (not owners)'; +COMMENT ON COLUMN wishlist_conditions.user_id IS 'Owner of this condition. Each user has their own goals on shared boards.'; +COMMENT ON COLUMN wishlist_items.author_id IS 'User who created this item (may differ from board owner on shared boards)'; +COMMENT ON COLUMN wishlist_items.board_id IS 'Board this item belongs to'; + diff --git a/play-life-backend/migrations/024_add_reward_policy.sql b/play-life-backend/migrations/024_add_reward_policy.sql new file mode 100644 index 0000000..0db06c8 --- /dev/null +++ b/play-life-backend/migrations/024_add_reward_policy.sql @@ -0,0 +1,13 @@ +-- Migration: Add reward_policy to tasks table +-- This migration adds reward_policy column for wishlist tasks +-- If the column already exists (from migration 023), this will be a no-op + +-- ============================================ +-- Modify: tasks - добавляем политику награждения для wishlist задач +-- ============================================ +ALTER TABLE tasks +ADD COLUMN IF NOT EXISTS reward_policy VARCHAR(20) DEFAULT 'personal'; + +COMMENT ON COLUMN tasks.reward_policy IS + 'For wishlist tasks: personal = only if user completes, shared = anyone completes'; + diff --git a/play-life-web/package.json b/play-life-web/package.json index c928831..e53e05c 100644 --- a/play-life-web/package.json +++ b/play-life-web/package.json @@ -1,6 +1,6 @@ { "name": "play-life-web", - "version": "3.12.2", + "version": "3.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/play-life-web/src/App.jsx b/play-life-web/src/App.jsx index 18dcc7d..e90286c 100644 --- a/play-life-web/src/App.jsx +++ b/play-life-web/src/App.jsx @@ -12,6 +12,8 @@ import TaskForm from './components/TaskForm.jsx' import Wishlist from './components/Wishlist' import WishlistForm from './components/WishlistForm' import WishlistDetail from './components/WishlistDetail' +import BoardForm from './components/BoardForm' +import BoardJoinPreview from './components/BoardJoinPreview' import TodoistIntegration from './components/TodoistIntegration' import TelegramIntegration from './components/TelegramIntegration' import { AuthProvider, useAuth } from './components/auth/AuthContext' @@ -24,7 +26,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', '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'] function AppContent() { const { authFetch, isAuthenticated, loading: authLoading } = useAuth() @@ -57,6 +59,8 @@ function AppContent() { wishlist: false, 'wishlist-form': false, 'wishlist-detail': false, + 'board-form': false, + 'board-join': false, profile: false, 'todoist-integration': false, 'telegram-integration': false, @@ -76,6 +80,8 @@ function AppContent() { wishlist: false, 'wishlist-form': false, 'wishlist-detail': false, + 'board-form': false, + 'board-join': false, profile: false, 'todoist-integration': false, 'telegram-integration': false, @@ -122,10 +128,25 @@ function AppContent() { if (isInitialized) return try { + // Проверяем путь /invite/:token для присоединения к доске + const path = window.location.pathname + if (path.startsWith('/invite/')) { + const token = path.replace('/invite/', '') + if (token) { + setActiveTab('board-join') + setLoadedTabs(prev => ({ ...prev, 'board-join': true })) + setTabParams({ inviteToken: token }) + setIsInitialized(true) + // Очищаем путь, оставляем только параметры + window.history.replaceState({}, '', '/?tab=board-join&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', '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'] if (tabFromUrl && validTabs.includes(tabFromUrl) && deepTabs.includes(tabFromUrl)) { // Если в URL есть глубокий таб, восстанавливаем его @@ -616,7 +637,7 @@ function AppContent() { // Для task-form и wishlist-form явно удаляем параметры, только если нет никаких параметров // task-form может иметь taskId (редактирование), wishlistId (создание из желания), или returnTo (возврат после создания) const isTaskFormWithNoParams = tab === 'task-form' && params.taskId === undefined && params.wishlistId === undefined && params.returnTo === undefined - const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined + const isWishlistFormWithNoParams = tab === 'wishlist-form' && params.wishlistId === undefined && params.newTaskId === undefined && params.boardId === undefined if (isTaskFormWithNoParams || isWishlistFormWithNoParams) { setTabParams({}) if (isNewTabMain) { @@ -655,7 +676,12 @@ function AppContent() { } // Обновляем список желаний при возврате из экрана редактирования if (activeTab === 'wishlist-form' && tab === 'wishlist') { - setTabParams({}) // Очищаем параметры при закрытии формы + // Сохраняем boardId из параметров или текущих tabParams + const savedBoardId = params.boardId || tabParams.boardId + // Параметры уже установлены в строке 649, но мы можем их обновить, чтобы сохранить boardId + if (savedBoardId) { + setTabParams(prev => ({ ...prev, boardId: savedBoardId })) + } setWishlistRefreshTrigger(prev => prev + 1) } // Загрузка данных произойдет в useEffect при изменении activeTab @@ -859,6 +885,8 @@ function AppContent() { onNavigate={handleNavigate} refreshTrigger={wishlistRefreshTrigger} isActive={activeTab === 'wishlist'} + initialBoardId={tabParams.boardId} + boardDeleted={tabParams.boardDeleted} /> )} @@ -866,11 +894,12 @@ function AppContent() { {loadedTabs['wishlist-form'] && (
)} @@ -886,6 +915,27 @@ function AppContent() { )} + {loadedTabs['board-form'] && ( +
+ setWishlistRefreshTrigger(prev => prev + 1)} + /> +
+ )} + + {loadedTabs['board-join'] && ( +
+ +
+ )} + {loadedTabs.profile && (
diff --git a/play-life-web/src/components/BoardForm.css b/play-life-web/src/components/BoardForm.css new file mode 100644 index 0000000..cf52f9c --- /dev/null +++ b/play-life-web/src/components/BoardForm.css @@ -0,0 +1,170 @@ +.board-form { + padding: 1rem; + max-width: 800px; + margin: 0 auto; + position: relative; + padding-bottom: 5rem; +} + +.board-form h2 { + font-size: 1.5rem; + font-weight: 600; + color: #1f2937; + margin: 0 0 1.5rem 0; +} + +.form-card { + background: white; + border-radius: 0.5rem; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.form-section { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e7eb; +} + +.form-section h3 { + font-size: 1rem; + font-weight: 600; + color: #374151; + margin: 0 0 1rem 0; +} + +/* Toggle switch */ +.toggle-field { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + user-select: none; +} + +.toggle-field input[type="checkbox"] { + display: none; +} + +.toggle-slider { + position: relative; + width: 48px; + height: 26px; + background: #d1d5db; + border-radius: 13px; + transition: background 0.2s; + flex-shrink: 0; +} + +.toggle-slider::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.toggle-field input:checked + .toggle-slider { + background: #6366f1; +} + +.toggle-field input:checked + .toggle-slider::after { + transform: translateX(22px); +} + +.toggle-label { + font-size: 0.95rem; + color: #374151; +} + +/* Invite link section */ +.invite-link-section { + margin-top: 1rem; +} + +.invite-url-row { + display: flex; + gap: 8px; + margin-bottom: 8px; +} + +.invite-url-input { + flex: 1; + padding: 10px 12px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.9rem; + background: #f9fafb; + color: #374151; + font-family: monospace; +} + +.copy-btn { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + background: #6366f1; + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1.1rem; + transition: background 0.2s; + flex-shrink: 0; +} + +.copy-btn:hover { + background: #4f46e5; +} + +.regenerate-btn { + width: 100%; + padding: 10px; + background: #f3f4f6; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 0.9rem; + color: #374151; + cursor: pointer; + transition: all 0.2s; +} + +.regenerate-btn:hover { + background: #e5e7eb; + border-color: #9ca3af; +} + +.invite-hint { + margin-top: 8px; + font-size: 0.85rem; + color: #6b7280; +} + +/* Delete button */ +.delete-board-btn { + display: block; + width: 100%; + margin-top: 1.5rem; + padding: 1rem; + background: transparent; + border: 1px solid #fecaca; + border-radius: 8px; + color: #ef4444; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.delete-board-btn:hover { + background: #fef2f2; + border-color: #ef4444; +} + diff --git a/play-life-web/src/components/BoardForm.jsx b/play-life-web/src/components/BoardForm.jsx new file mode 100644 index 0000000..4a2659e --- /dev/null +++ b/play-life-web/src/components/BoardForm.jsx @@ -0,0 +1,279 @@ +import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' +import BoardMembers from './BoardMembers' +import Toast from './Toast' +import './BoardForm.css' + +function BoardForm({ boardId, onNavigate, onSaved }) { + const { authFetch } = useAuth() + const [name, setName] = useState('') + const [inviteEnabled, setInviteEnabled] = useState(false) + const [inviteURL, setInviteURL] = useState('') + const [loading, setLoading] = useState(false) + const [loadingBoard, setLoadingBoard] = useState(false) + const [copied, setCopied] = useState(false) + const [toastMessage, setToastMessage] = useState(null) + + const isEdit = !!boardId + + useEffect(() => { + if (boardId) { + fetchBoard() + } + }, [boardId]) + + const fetchBoard = async () => { + setLoadingBoard(true) + try { + const res = await authFetch(`/api/wishlist/boards/${boardId}`) + if (res.ok) { + const data = await res.json() + setName(data.name) + setInviteEnabled(data.invite_enabled) + setInviteURL(data.invite_url || '') + } else { + setToastMessage({ text: 'Ошибка загрузки доски', type: 'error' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка загрузки', type: 'error' }) + } finally { + setLoadingBoard(false) + } + } + + const handleSave = async () => { + if (!name.trim()) { + setToastMessage({ text: 'Введите название доски', type: 'error' }) + return + } + + setLoading(true) + try { + const url = boardId + ? `/api/wishlist/boards/${boardId}` + : '/api/wishlist/boards' + + const res = await authFetch(url, { + method: boardId ? 'PUT' : 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name.trim(), + invite_enabled: inviteEnabled + }) + }) + + if (res.ok) { + const data = await res.json() + if (data.invite_url) { + setInviteURL(data.invite_url) + } + onSaved?.() + if (!boardId) { + // При создании возвращаемся назад + onNavigate('wishlist', { boardId: data.id }) + } else { + // При редактировании возвращаемся на доску + onNavigate('wishlist', { boardId: boardId }) + } + } else { + const err = await res.json() + setToastMessage({ text: err.error || 'Ошибка сохранения', type: 'error' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка сохранения', type: 'error' }) + } finally { + setLoading(false) + } + } + + const handleRegenerateLink = async () => { + try { + const res = await authFetch(`/api/wishlist/boards/${boardId}/regenerate-invite`, { + method: 'POST' + }) + if (res.ok) { + const data = await res.json() + setInviteURL(data.invite_url) + setInviteEnabled(true) + setToastMessage({ text: 'Ссылка обновлена', type: 'success' }) + } else { + setToastMessage({ text: 'Ошибка обновления ссылки', type: 'error' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка', type: 'error' }) + } + } + + const handleCopyLink = () => { + navigator.clipboard.writeText(inviteURL) + setCopied(true) + setToastMessage({ text: 'Ссылка скопирована', type: 'success' }) + setTimeout(() => setCopied(false), 2000) + } + + const handleToggleInvite = async (enabled) => { + setInviteEnabled(enabled) + + if (boardId && enabled && !inviteURL) { + // Автоматически генерируем ссылку при включении + await handleRegenerateLink() + } else if (boardId) { + // Просто обновляем статус + try { + await authFetch(`/api/wishlist/boards/${boardId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ invite_enabled: enabled }) + }) + } catch (err) { + console.error('Error updating invite status:', err) + } + } + } + + const handleDelete = async () => { + if (!window.confirm('Удалить доску? Все желания на ней будут удалены.')) return + + try { + const res = await authFetch(`/api/wishlist/boards/${boardId}`, { + method: 'DELETE' + }) + if (res.ok) { + onSaved?.() + // Передаём флаг, что доска удалена, чтобы Wishlist выбрал первую доступную + onNavigate('wishlist', { boardDeleted: true }) + } else { + setToastMessage({ text: 'Ошибка удаления', type: 'error' }) + } + } catch (err) { + setToastMessage({ text: 'Ошибка удаления', type: 'error' }) + } + } + + const handleClose = () => { + onNavigate('wishlist') + } + + if (loadingBoard) { + return ( +
+
+
+
+
Загрузка...
+
+
+
+ ) + } + + return ( +
+ + +

{isEdit ? 'Настройки доски' : 'Новая доска'}

+ +
+
+ + setName(e.target.value)} + placeholder="Название доски" + /> +
+ + {isEdit && ( + <> + {/* Настройки доступа */} +
+

Доступ по ссылке

+ + + + {inviteEnabled && inviteURL && ( +
+
+ + +
+ +

+ Пользователь, открывший ссылку, сможет присоединиться к доске +

+
+ )} +
+ + {/* Список участников */} + { + setToastMessage({ text: 'Участник удалён', type: 'success' }) + }} + /> + + )} + +
+ + +
+
+ + {isEdit && ( + + )} + + {toastMessage && ( + setToastMessage(null)} + /> + )} +
+ ) +} + +export default BoardForm + diff --git a/play-life-web/src/components/BoardJoinPreview.css b/play-life-web/src/components/BoardJoinPreview.css new file mode 100644 index 0000000..57c4610 --- /dev/null +++ b/play-life-web/src/components/BoardJoinPreview.css @@ -0,0 +1,199 @@ +.board-join-preview { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.preview-loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + color: white; +} + +.preview-loading p { + margin: 0; + font-size: 1.1rem; +} + +.preview-card { + background: white; + border-radius: 24px; + padding: 2rem; + max-width: 400px; + width: 100%; + text-align: center; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.preview-card.error-card { + max-width: 360px; +} + +.invite-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.error-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.preview-card h2 { + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + margin: 0 0 1.5rem 0; +} + +.board-info { + background: #f9fafb; + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 1.5rem; +} + +.board-name { + font-size: 1.25rem; + font-weight: 600; + color: #1f2937; + margin-bottom: 0.75rem; +} + +.board-owner, +.board-members { + display: flex; + justify-content: center; + gap: 0.5rem; + font-size: 0.95rem; + color: #6b7280; + margin-top: 0.5rem; +} + +.board-owner .value, +.board-members .value { + color: #374151; + font-weight: 500; +} + +.error-text { + color: #ef4444; + margin: 0 0 1.5rem 0; +} + +.join-error { + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 0.75rem; + color: #ef4444; + font-size: 0.9rem; + margin-bottom: 1rem; +} + +.join-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: 1rem; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: white; + border: none; + border-radius: 12px; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.join-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(99, 102, 241, 0.4); +} + +.join-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +.spinner-small { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.login-prompt { + text-align: center; +} + +.login-prompt p { + color: #6b7280; + margin: 0 0 1rem 0; + font-size: 0.95rem; +} + +.login-btn { + width: 100%; + padding: 1rem; + background: #6366f1; + color: white; + border: none; + border-radius: 12px; + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.login-btn:hover { + background: #4f46e5; +} + +.back-btn { + width: 100%; + padding: 1rem; + background: #f3f4f6; + color: #374151; + border: none; + border-radius: 12px; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.back-btn:hover { + background: #e5e7eb; +} + +.cancel-link { + margin-top: 1rem; + background: none; + border: none; + color: #9ca3af; + font-size: 0.95rem; + cursor: pointer; + transition: color 0.2s; +} + +.cancel-link:hover { + color: #6b7280; +} + diff --git a/play-life-web/src/components/BoardJoinPreview.jsx b/play-life-web/src/components/BoardJoinPreview.jsx new file mode 100644 index 0000000..ab38abf --- /dev/null +++ b/play-life-web/src/components/BoardJoinPreview.jsx @@ -0,0 +1,156 @@ +import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' +import './BoardJoinPreview.css' + +function BoardJoinPreview({ inviteToken, onNavigate }) { + const { authFetch, user } = useAuth() + const [board, setBoard] = useState(null) + const [loading, setLoading] = useState(true) + const [joining, setJoining] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + if (inviteToken) { + fetchBoardInfo() + } + }, [inviteToken]) + + const fetchBoardInfo = async () => { + try { + const res = await authFetch(`/api/wishlist/invite/${inviteToken}`) + + if (res.ok) { + setBoard(await res.json()) + } else { + const err = await res.json() + setError(err.error || 'Ссылка недействительна или устарела') + } + } catch (err) { + setError('Ошибка загрузки') + } finally { + setLoading(false) + } + } + + const handleJoin = async () => { + if (!user) { + // Сохраняем токен для возврата после логина + sessionStorage.setItem('pendingInviteToken', inviteToken) + onNavigate('login') + return + } + + setJoining(true) + setError('') + + try { + const res = await authFetch(`/api/wishlist/invite/${inviteToken}/join`, { + method: 'POST' + }) + + if (res.ok) { + const data = await res.json() + // Переходим на доску + onNavigate('wishlist', { boardId: data.board.id }) + } else { + const err = await res.json() + setError(err.error || 'Ошибка при присоединении') + } + } catch (err) { + setError('Ошибка при присоединении') + } finally { + setJoining(false) + } + } + + const handleGoBack = () => { + onNavigate('wishlist') + } + + if (loading) { + return ( +
+
+
+

Загрузка...

+
+
+ ) + } + + if (error && !board) { + return ( +
+
+
+

Ошибка

+

{error}

+ +
+
+ ) + } + + return ( +
+
+
+

Приглашение на доску

+ +
+
{board.name}
+
+ Владелец: + {board.owner_name} +
+ {board.member_count > 0 && ( +
+ Участников: + {board.member_count} +
+ )} +
+ + {error && ( +
{error}
+ )} + + {user ? ( + + ) : ( +
+

Для присоединения необходимо войти в аккаунт

+ +
+ )} + + +
+
+ ) +} + +export default BoardJoinPreview + diff --git a/play-life-web/src/components/BoardMembers.css b/play-life-web/src/components/BoardMembers.css new file mode 100644 index 0000000..4936f2a --- /dev/null +++ b/play-life-web/src/components/BoardMembers.css @@ -0,0 +1,132 @@ +.board-members-section { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #e5e7eb; +} + +.board-members-section h3 { + font-size: 1rem; + font-weight: 600; + color: #374151; + margin: 0 0 1rem 0; +} + +.members-loading { + padding: 1rem; + text-align: center; + color: #9ca3af; +} + +.no-members { + padding: 1.5rem; + text-align: center; + background: #f9fafb; + border-radius: 8px; +} + +.no-members p { + margin: 0; + color: #6b7280; +} + +.no-members .hint { + margin-top: 0.5rem; + font-size: 0.85rem; + color: #9ca3af; +} + +.members-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.member-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + background: #f9fafb; + border-radius: 8px; + transition: background 0.15s; +} + +.member-item:hover { + background: #f3f4f6; +} + +.member-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1, #8b5cf6); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 1rem; + flex-shrink: 0; +} + +.member-info { + flex: 1; + min-width: 0; +} + +.member-name { + font-weight: 500; + color: #1f2937; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.member-date { + font-size: 0.8rem; + color: #9ca3af; + margin-top: 2px; +} + +.remove-member-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: transparent; + border: 1px solid #e5e7eb; + border-radius: 6px; + color: #9ca3af; + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; +} + +.remove-member-btn:hover:not(:disabled) { + background: #fef2f2; + border-color: #fecaca; + color: #ef4444; +} + +.remove-member-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.spinner-small { + width: 14px; + height: 14px; + border: 2px solid #d1d5db; + border-top-color: #6366f1; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + diff --git a/play-life-web/src/components/BoardMembers.jsx b/play-life-web/src/components/BoardMembers.jsx new file mode 100644 index 0000000..da22196 --- /dev/null +++ b/play-life-web/src/components/BoardMembers.jsx @@ -0,0 +1,113 @@ +import React, { useState, useEffect } from 'react' +import { useAuth } from './auth/AuthContext' +import './BoardMembers.css' + +function BoardMembers({ boardId, onMemberRemoved }) { + const { authFetch } = useAuth() + const [members, setMembers] = useState([]) + const [loading, setLoading] = useState(true) + const [removingId, setRemovingId] = useState(null) + + useEffect(() => { + if (boardId) { + fetchMembers() + } + }, [boardId]) + + const fetchMembers = async () => { + try { + const res = await authFetch(`/api/wishlist/boards/${boardId}/members`) + if (res.ok) { + const data = await res.json() + setMembers(data || []) + } + } catch (err) { + console.error('Error fetching members:', err) + } finally { + setLoading(false) + } + } + + const handleRemoveMember = async (userId) => { + if (!window.confirm('Удалить участника из доски?')) return + + setRemovingId(userId) + try { + const res = await authFetch(`/api/wishlist/boards/${boardId}/members/${userId}`, { + method: 'DELETE' + }) + if (res.ok) { + setMembers(members.filter(m => m.user_id !== userId)) + onMemberRemoved?.() + } + } catch (err) { + console.error('Error removing member:', err) + } finally { + setRemovingId(null) + } + } + + const formatDate = (dateStr) => { + const date = new Date(dateStr) + return date.toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'short', + year: 'numeric' + }) + } + + if (loading) { + return ( +
+

Участники

+
Загрузка...
+
+ ) + } + + return ( +
+

Участники ({members.length})

+ + {members.length === 0 ? ( +
+

Пока никто не присоединился к доске

+

Поделитесь ссылкой, чтобы пригласить участников

+
+ ) : ( +
+ {members.map(member => ( +
+
+ {(member.name || member.email).charAt(0).toUpperCase()} +
+
+
+ {member.name || member.email} +
+
+ Присоединился {formatDate(member.joined_at)} +
+
+ +
+ ))} +
+ )} +
+ ) +} + +export default BoardMembers + diff --git a/play-life-web/src/components/BoardSelector.css b/play-life-web/src/components/BoardSelector.css new file mode 100644 index 0000000..d563de7 --- /dev/null +++ b/play-life-web/src/components/BoardSelector.css @@ -0,0 +1,242 @@ +.board-selector { + position: relative; + max-width: 42rem; + margin: 0 auto; + padding-top: 0; + padding-bottom: 16px; +} + +/* Дополнительный отступ сверху на больших экранах, чтобы соответствовать кнопке "Добавить" на экране задач */ +@media (min-width: 768px) { + .board-selector { + margin-top: 0.5rem; /* 8px - разница между md:p-8 (32px) и md:p-6 (24px) */ + } +} + +.board-header { + display: flex; + gap: 12px; + align-items: center; +} + +/* Основная кнопка-pill */ +.board-pill { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + height: 52px; + padding: 0 20px; + background: white; + border: none; + border-radius: 26px; + font-size: 17px; + font-weight: 500; + color: #1f2937; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06); +} + +.board-pill:hover:not(:disabled) { + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08); + transform: translateY(-1px); +} + +.board-pill:active:not(:disabled) { + transform: translateY(0); +} + +.board-pill.open { + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2), 0 2px 4px rgba(0, 0, 0, 0.08); +} + +.board-pill:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.shared-icon { + font-size: 16px; + flex-shrink: 0; +} + +.board-label { + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + color: #9ca3af; + flex-shrink: 0; + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.chevron.rotated { + transform: rotate(180deg); +} + +/* Кнопка действия (настройки/выход) */ +.board-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 52px; + height: 52px; + padding: 0; + background: white; + border: none; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + color: #6b7280; + flex-shrink: 0; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06); +} + +.board-action-btn:hover { + background: #f9fafb; + color: #374151; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); +} + +.board-action-btn:active { + transform: translateY(0); +} + +.board-action-btn svg { + width: 22px; + height: 22px; +} + +/* Выпадающий список */ +.board-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: white; + border-radius: 18px; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12), 0 2px 10px rgba(0, 0, 0, 0.08); + z-index: 100; + overflow: hidden; + opacity: 0; + visibility: hidden; + transform: translateY(-8px) scale(0.98); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.board-dropdown.visible { + opacity: 1; + visibility: visible; + transform: translateY(0) scale(1); +} + +.dropdown-content { + padding: 10px; +} + +.dropdown-list { + max-height: 280px; + overflow-y: auto; +} + +.dropdown-empty { + padding: 28px 16px; + text-align: center; + color: #9ca3af; + font-size: 15px; +} + +/* Элементы списка */ +.dropdown-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 14px 16px; + border: none; + background: transparent; + border-radius: 12px; + font-size: 16px; + cursor: pointer; + transition: all 0.15s ease; + text-align: left; + color: #374151; +} + +.dropdown-item:hover { + background: #f3f4f6; +} + +.dropdown-item.selected { + background: linear-gradient(135deg, #667eea10 0%, #764ba210 100%); + color: #4f46e5; +} + +.dropdown-item.selected:hover { + background: linear-gradient(135deg, #667eea18 0%, #764ba218 100%); +} + +.item-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.item-meta { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.item-badge { + font-size: 14px; +} + +.item-members { + display: flex; + align-items: center; + justify-content: center; + min-width: 26px; + height: 26px; + padding: 0 8px; + background: #e5e7eb; + border-radius: 13px; + font-size: 13px; + font-weight: 600; + color: #6b7280; +} + +.check-icon { + color: #4f46e5; +} + +/* Кнопка добавления доски */ +.dropdown-item.add-board { + margin-top: 6px; + padding-top: 14px; + border-top: 1px solid #f3f4f6; + border-radius: 0 0 12px 12px; + color: #667eea; + font-weight: 500; + gap: 12px; + justify-content: flex-start; +} + +.dropdown-item.add-board:hover { + background: linear-gradient(135deg, #667eea08 0%, #764ba208 100%); +} + +.dropdown-item.add-board svg { + flex-shrink: 0; + width: 20px; + height: 20px; +} diff --git a/play-life-web/src/components/BoardSelector.jsx b/play-life-web/src/components/BoardSelector.jsx new file mode 100644 index 0000000..73e05f1 --- /dev/null +++ b/play-life-web/src/components/BoardSelector.jsx @@ -0,0 +1,132 @@ +import React, { useState, useEffect, useRef } from 'react' +import './BoardSelector.css' + +function BoardSelector({ + boards, + selectedBoardId, + onBoardChange, + onBoardEdit, + onAddBoard, + loading +}) { + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + const selectedBoard = boards.find(b => b.id === selectedBoardId) + + // Закрытие при клике снаружи + useEffect(() => { + const handleClickOutside = (e) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const handleSelectBoard = (board) => { + onBoardChange(board.id) + setIsOpen(false) + } + + return ( +
+
+ + + {selectedBoard && ( + + )} +
+ +
+
+ {boards.length === 0 ? ( +
+ Нет досок +
+ ) : ( +
+ {boards.map(board => ( + + ))} +
+ )} + + +
+
+
+ ) +} + +export default BoardSelector diff --git a/play-life-web/src/components/TaskForm.jsx b/play-life-web/src/components/TaskForm.jsx index 9b015b9..5afbfe4 100644 --- a/play-life-web/src/components/TaskForm.jsx +++ b/play-life-web/src/components/TaskForm.jsx @@ -24,6 +24,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa const [isDeleting, setIsDeleting] = useState(false) const [wishlistInfo, setWishlistInfo] = useState(null) // Информация о связанном желании const [currentWishlistId, setCurrentWishlistId] = useState(null) // Текущий wishlist_id задачи + const [rewardPolicy, setRewardPolicy] = useState('personal') // Политика награждения: 'personal' или 'general' // Test-specific state const [isTest, setIsTest] = useState(isTestFromProps) const [wordsCount, setWordsCount] = useState('10') @@ -122,6 +123,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa } else { setCurrentWishlistId(null) setWishlistInfo(null) + setRewardPolicy('personal') // Сбрасываем при отвязке } } }, [taskId, wishlistId, authFetch]) @@ -339,9 +341,16 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa } catch (err) { console.error('Error loading wishlist info:', err) } + // Загружаем политику награждения + if (data.task.reward_policy) { + setRewardPolicy(data.task.reward_policy) + } else { + setRewardPolicy('personal') // Значение по умолчанию + } } else { setCurrentWishlistId(null) setWishlistInfo(null) + setRewardPolicy('personal') // Сбрасываем при отвязке } // Загружаем информацию о тесте, если есть config_id @@ -628,6 +637,7 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa wishlist_id: taskId ? (currentWishlistId && !wishlistInfo ? null : undefined) : (currentWishlistId || undefined), + reward_policy: (wishlistInfo || currentWishlistId) ? rewardPolicy : undefined, rewards: rewards.map(r => ({ position: r.position, project_name: r.project_name.trim(), @@ -798,6 +808,23 @@ function TaskForm({ onNavigate, taskId, wishlistId, isTest: isTestFromProps = fa )}
+
+ + + + {rewardPolicy === 'personal' + ? 'Задача выполняется только если вы сами завершили желание. Если другой пользователь завершит желание, задача будет удалена.' + : 'Задача выполняется если кто-либо (неважно кто) отметил желание завершённым.'} + +
)} diff --git a/play-life-web/src/components/Wishlist.css b/play-life-web/src/components/Wishlist.css index 27ad75e..c8909c7 100644 --- a/play-life-web/src/components/Wishlist.css +++ b/play-life-web/src/components/Wishlist.css @@ -4,6 +4,13 @@ padding-bottom: 5rem; } +.wishlist-loading { + display: flex; + justify-content: center; + align-items: center; + padding: 3rem; +} + .add-wishlist-button { background: transparent; border: 2px dashed #6b8dd6; diff --git a/play-life-web/src/components/Wishlist.jsx b/play-life-web/src/components/Wishlist.jsx index b1e1f67..792af13 100644 --- a/play-life-web/src/components/Wishlist.jsx +++ b/play-life-web/src/components/Wishlist.jsx @@ -1,18 +1,44 @@ import React, { useState, useEffect, useRef } from 'react' import { useAuth } from './auth/AuthContext' +import BoardSelector from './BoardSelector' import LoadingError from './LoadingError' import './Wishlist.css' const API_URL = '/api/wishlist' -const CACHE_KEY = 'wishlist_cache' -const CACHE_COMPLETED_KEY = 'wishlist_completed_cache' +const BOARDS_CACHE_KEY = 'wishlist_boards_cache' +const ITEMS_CACHE_KEY = 'wishlist_items_cache' +const SELECTED_BOARD_KEY = 'wishlist_selected_board_id' -function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { +function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false, initialBoardId = null, boardDeleted = false }) { const { authFetch } = useAuth() + const [boards, setBoards] = useState([]) + + // Восстанавливаем выбранную доску из localStorage или используем initialBoardId + const getInitialBoardId = () => { + if (initialBoardId) return initialBoardId + return getSavedBoardId() + } + + // Получает сохранённую доску из localStorage + const getSavedBoardId = () => { + try { + const saved = localStorage.getItem(SELECTED_BOARD_KEY) + if (saved) { + const boardId = parseInt(saved, 10) + if (!isNaN(boardId)) return boardId + } + } catch (err) { + console.error('Error loading selected board from cache:', err) + } + return null + } + + const [selectedBoardId, setSelectedBoardIdState] = useState(getInitialBoardId) const [items, setItems] = useState([]) const [completed, setCompleted] = useState([]) const [completedCount, setCompletedCount] = useState(0) const [loading, setLoading] = useState(true) + const [boardsLoading, setBoardsLoading] = useState(true) const [error, setError] = useState('') const [completedExpanded, setCompletedExpanded] = useState(false) const [completedLoading, setCompletedLoading] = useState(false) @@ -22,19 +48,66 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { const initialFetchDoneRef = useRef(false) const prevIsActiveRef = useRef(isActive) - // Проверка наличия кэша - const hasCache = () => { + // Обёртка для setSelectedBoardId с сохранением в localStorage + const setSelectedBoardId = (boardId) => { + setSelectedBoardIdState(boardId) try { - return localStorage.getItem(CACHE_KEY) !== null + if (boardId) { + localStorage.setItem(SELECTED_BOARD_KEY, String(boardId)) + } else { + localStorage.removeItem(SELECTED_BOARD_KEY) + } } catch (err) { - return false + console.error('Error saving selected board to cache:', err) } } - // Загрузка основных данных из кэша - const loadFromCache = () => { + // Загрузка досок из кэша + const loadBoardsFromCache = () => { try { - const cached = localStorage.getItem(CACHE_KEY) + const cached = localStorage.getItem(BOARDS_CACHE_KEY) + if (cached) { + const data = JSON.parse(cached) + setBoards(data.boards || []) + // Проверяем, что сохранённая доска существует в списке + if (selectedBoardId) { + const boardExists = data.boards?.some(b => b.id === selectedBoardId) + if (!boardExists && data.boards?.length > 0) { + setSelectedBoardId(data.boards[0].id) + } + } else if (data.boards?.length > 0) { + // Пытаемся восстановить из localStorage + const savedBoardId = getSavedBoardId() + if (savedBoardId && data.boards.some(b => b.id === savedBoardId)) { + setSelectedBoardId(savedBoardId) + } else { + setSelectedBoardId(data.boards[0].id) + } + } + return true + } + } catch (err) { + console.error('Error loading boards from cache:', err) + } + return false + } + + // Сохранение досок в кэш + const saveBoardsToCache = (boardsData) => { + try { + localStorage.setItem(BOARDS_CACHE_KEY, JSON.stringify({ + boards: boardsData, + timestamp: Date.now() + })) + } catch (err) { + console.error('Error saving boards to cache:', err) + } + } + + // Загрузка желаний из кэша (по board_id) + const loadItemsFromCache = (boardId) => { + try { + const cached = localStorage.getItem(`${ITEMS_CACHE_KEY}_${boardId}`) if (cached) { const data = JSON.parse(cached) setItems(data.items || []) @@ -42,61 +115,72 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { return true } } catch (err) { - console.error('Error loading from cache:', err) + console.error('Error loading items from cache:', err) } return false } - // Загрузка завершённых из кэша - const loadCompletedFromCache = () => { + // Сохранение желаний в кэш + const saveItemsToCache = (boardId, itemsData, count) => { try { - const cached = localStorage.getItem(CACHE_COMPLETED_KEY) - if (cached) { - const data = JSON.parse(cached) - setCompleted(data || []) - return true - } - } catch (err) { - console.error('Error loading completed from cache:', err) - } - return false - } - - // Сохранение основных данных в кэш - const saveToCache = (itemsData, count) => { - try { - localStorage.setItem(CACHE_KEY, JSON.stringify({ + localStorage.setItem(`${ITEMS_CACHE_KEY}_${boardId}`, JSON.stringify({ items: itemsData, completedCount: count, timestamp: Date.now() })) } catch (err) { - console.error('Error saving to cache:', err) + console.error('Error saving items to cache:', err) } } - // Сохранение завершённых в кэш - const saveCompletedToCache = (completedData) => { + // Загрузка списка досок + const fetchBoards = async () => { try { - localStorage.setItem(CACHE_COMPLETED_KEY, JSON.stringify(completedData)) + const response = await authFetch(`${API_URL}/boards`) + if (response.ok) { + const data = await response.json() + setBoards(data || []) + saveBoardsToCache(data || []) + + // Проверяем, что выбранная доска существует в списке + if (selectedBoardId) { + const boardExists = data?.some(b => b.id === selectedBoardId) + if (!boardExists && data?.length > 0) { + // Сохранённая доска не существует, выбираем первую + setSelectedBoardId(data[0].id) + } + } else if (data?.length > 0) { + // Пытаемся восстановить из localStorage + const savedBoardId = getSavedBoardId() + if (savedBoardId && data.some(b => b.id === savedBoardId)) { + setSelectedBoardId(savedBoardId) + } else { + setSelectedBoardId(data[0].id) + } + } + } } catch (err) { - console.error('Error saving completed to cache:', err) + console.error('Error fetching boards:', err) + } finally { + setBoardsLoading(false) } } - // Загрузка основного списка - const fetchWishlist = async () => { - if (fetchingRef.current) return + // Загрузка желаний выбранной доски + const fetchItems = async () => { + if (!selectedBoardId || fetchingRef.current) return fetchingRef.current = true try { const hasDataInState = items.length > 0 || completedCount > 0 - const cacheExists = hasCache() - if (!hasDataInState && !cacheExists) { - setLoading(true) + if (!hasDataInState) { + const cacheLoaded = loadItemsFromCache(selectedBoardId) + if (!cacheLoaded) { + setLoading(true) + } } - const response = await authFetch(API_URL) + const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/items`) if (!response.ok) { throw new Error('Ошибка при загрузке желаний') @@ -108,11 +192,11 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { setItems(allItems) setCompletedCount(count) - saveToCache(allItems, count) + saveItemsToCache(selectedBoardId, allItems, count) setError('') } catch (err) { setError(err.message) - if (!hasCache()) { + if (!loadItemsFromCache(selectedBoardId)) { setItems([]) setCompletedCount(0) } @@ -122,14 +206,15 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { } } - // Загрузка завершённых + // Загрузка завершённых для текущей доски const fetchCompleted = async () => { - if (fetchingCompletedRef.current) return + if (fetchingCompletedRef.current || !selectedBoardId) return fetchingCompletedRef.current = true try { setCompletedLoading(true) - const response = await authFetch(`${API_URL}/completed`) + // Используем новый API для получения завершённых на доске + const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/completed`) if (!response.ok) { throw new Error('Ошибка при загрузке завершённых желаний') @@ -138,7 +223,6 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { const data = await response.json() const completedData = Array.isArray(data) ? data : [] setCompleted(completedData) - saveCompletedToCache(completedData) } catch (err) { console.error('Error fetching completed items:', err) setCompleted([]) @@ -153,85 +237,173 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { if (!initialFetchDoneRef.current) { initialFetchDoneRef.current = true - // Загружаем из кэша - const cacheLoaded = loadFromCache() + // Загружаем доски из кэша + const boardsCacheLoaded = loadBoardsFromCache() + if (boardsCacheLoaded) { + setBoardsLoading(false) + } + + // Загружаем доски с сервера + fetchBoards() + } + }, []) + + // Загружаем желания при смене доски + useEffect(() => { + if (selectedBoardId) { + // Сбрасываем состояние + setItems([]) + setCompletedCount(0) + setCompleted([]) + setCompletedExpanded(false) + setLoading(true) + + // Пробуем загрузить из кэша + const cacheLoaded = loadItemsFromCache(selectedBoardId) if (cacheLoaded) { setLoading(false) } // Загружаем свежие данные - fetchWishlist() - - // Если список завершённых раскрыт - загружаем их тоже - if (completedExpanded) { - loadCompletedFromCache() - fetchCompleted() - } + fetchItems() } - }, []) + }, [selectedBoardId]) - // Обработка активации/деактивации таба + // Обновление при активации таба useEffect(() => { const wasActive = prevIsActiveRef.current prevIsActiveRef.current = isActive - // Пропускаем первую инициализацию (она обрабатывается отдельно) if (!initialFetchDoneRef.current) return - // Когда таб становится видимым if (isActive && !wasActive) { - // Показываем кэш, если есть данные - const hasDataInState = items.length > 0 || completedCount > 0 - if (!hasDataInState) { - const cacheLoaded = loadFromCache() - if (cacheLoaded) { - setLoading(false) - } else { - setLoading(true) - } - } - - // Всегда загружаем свежие данные основного списка - fetchWishlist() - - // Если список завершённых раскрыт - загружаем их тоже - if (completedExpanded && completedCount > 0) { - fetchCompleted() + fetchBoards() + if (selectedBoardId) { + fetchItems() } } }, [isActive]) - // Обновляем данные при изменении refreshTrigger + // Обновление при refreshTrigger useEffect(() => { - if (refreshTrigger > 0) { - fetchWishlist() + if (refreshTrigger > 0 && selectedBoardId) { + // Очищаем кэш для текущей доски, чтобы загрузить свежие данные + try { + localStorage.removeItem(`${ITEMS_CACHE_KEY}_${selectedBoardId}`) + } catch (err) { + console.error('Error clearing cache:', err) + } + fetchBoards() + fetchItems() if (completedExpanded && completedCount > 0) { fetchCompleted() } } - }, [refreshTrigger]) + }, [refreshTrigger, selectedBoardId]) + + // Обновление при initialBoardId (когда создана новая доска или переход по ссылке) + useEffect(() => { + if (initialBoardId && initialBoardId !== selectedBoardId) { + // Сбрасываем флаг загрузки, чтобы не блокировать новую загрузку + fetchingRef.current = false + + // Обновляем список досок (чтобы новая доска появилась) + fetchBoards().then(() => { + // Переключаемся на новую доску после обновления списка + // Это вызовет useEffect для selectedBoardId, который загрузит данные + setSelectedBoardId(initialBoardId) + }) + } + }, [initialBoardId]) + + // Обработка удаления доски - выбираем первую доступную + useEffect(() => { + if (boardDeleted && boards.length > 0) { + // Очищаем текущие данные + setItems([]) + setCompletedCount(0) + setCompleted([]) + setCompletedExpanded(false) + setLoading(true) + + // Обновляем список досок и выбираем первую + fetchBoards().then(() => { + // fetchBoards обновит boards, но мы уже в этом useEffect + // selectedBoardId обновится автоматически в useEffect ниже + }) + } + }, [boardDeleted]) + + // Если текущая доска больше не существует в списке - выбираем первую + useEffect(() => { + if (boards.length > 0 && selectedBoardId) { + const boardExists = boards.some(b => b.id === selectedBoardId) + if (!boardExists) { + setSelectedBoardId(boards[0].id) + } + } + }, [boards, selectedBoardId]) + + const handleBoardChange = (boardId) => { + setSelectedBoardId(boardId) + } + + const handleBoardEdit = () => { + const board = boards.find(b => b.id === selectedBoardId) + if (board?.is_owner) { + onNavigate?.('board-form', { boardId: selectedBoardId }) + } else { + // Показать подтверждение выхода + handleLeaveBoard() + } + } + + const handleLeaveBoard = async () => { + if (!window.confirm('Отвязаться от этой доски? Вы больше не будете видеть её желания.')) return + + try { + const response = await authFetch(`${API_URL}/boards/${selectedBoardId}/leave`, { + method: 'POST' + }) + + if (response.ok) { + // Убираем доску из списка + const newBoards = boards.filter(b => b.id !== selectedBoardId) + setBoards(newBoards) + saveBoardsToCache(newBoards) + + // Выбираем первую доску + if (newBoards.length > 0) { + setSelectedBoardId(newBoards[0].id) + } else { + setSelectedBoardId(null) + setItems([]) + } + } + } catch (err) { + console.error('Error leaving board:', err) + } + } + + const handleAddBoard = () => { + onNavigate?.('board-form', { boardId: null }) + } const handleToggleCompleted = () => { const newExpanded = !completedExpanded setCompletedExpanded(newExpanded) - // При раскрытии загружаем завершённые if (newExpanded && completedCount > 0) { - // Показываем из кэша если есть - if (completed.length === 0) { - loadCompletedFromCache() - } - // Загружаем свежие данные fetchCompleted() } } const handleAddClick = () => { - onNavigate?.('wishlist-form', { wishlistId: undefined }) + onNavigate?.('wishlist-form', { wishlistId: undefined, boardId: selectedBoardId }) } const handleItemClick = (item) => { - onNavigate?.('wishlist-detail', { wishlistId: item.id }) + onNavigate?.('wishlist-detail', { wishlistId: item.id, boardId: selectedBoardId }) } const handleMenuClick = (item, e) => { @@ -241,7 +413,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { const handleEdit = () => { if (selectedItem) { - onNavigate?.('wishlist-form', { wishlistId: selectedItem.id }) + onNavigate?.('wishlist-form', { wishlistId: selectedItem.id, boardId: selectedBoardId }) setSelectedItem(null) } } @@ -259,30 +431,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { } setSelectedItem(null) - await fetchWishlist() - if (completedExpanded) { - await fetchCompleted() - } - } catch (err) { - setError(err.message) - setSelectedItem(null) - } - } - - const handleComplete = async () => { - if (!selectedItem) return - - try { - const response = await authFetch(`${API_URL}/${selectedItem.id}/complete`, { - method: 'POST', - }) - - if (!response.ok) { - throw new Error('Ошибка при завершении') - } - - setSelectedItem(null) - await fetchWishlist() + await fetchItems() if (completedExpanded) { await fetchCompleted() } @@ -307,7 +456,7 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { const newItem = await response.json() setSelectedItem(null) - onNavigate?.('wishlist-form', { wishlistId: newItem.id }) + onNavigate?.('wishlist-form', { wishlistId: newItem.id, boardId: selectedBoardId }) } catch (err) { setError(err.message) setSelectedItem(null) @@ -430,7 +579,8 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { ) } - if (loading) { + // Показываем loading только если и доски и желания грузятся + if (boardsLoading && loading) { return (
@@ -443,51 +593,77 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { ) } - if (error) { + if (error && items.length === 0) { return (
- fetchWishlist()} /> + + fetchItems()} />
) } return (
- {/* Основной список (разблокированные и заблокированные вместе) */} -
- {items.map(renderItem)} - -
+ {/* Селектор доски */} + - {/* Завершённые - показываем только если есть завершённые желания */} - {completedCount > 0 && ( + {/* Основной список */} + {loading ? ( +
+
+
+ ) : ( <> -
-
- {completedExpanded && ( + + {/* Завершённые */} + {completedCount > 0 && ( <> - {completedLoading ? ( -
-
-
- ) : ( -
- {completed.map(renderItem)} -
+
+ +
+ {completedExpanded && ( + <> + {completedLoading ? ( +
+
+
+ ) : ( +
+ {completed.map(renderItem)} +
+ )} + )} )} @@ -520,4 +696,3 @@ function Wishlist({ onNavigate, refreshTrigger = 0, isActive = false }) { } export default Wishlist - diff --git a/play-life-web/src/components/WishlistDetail.jsx b/play-life-web/src/components/WishlistDetail.jsx index f83e69b..639d89c 100644 --- a/play-life-web/src/components/WishlistDetail.jsx +++ b/play-life-web/src/components/WishlistDetail.jsx @@ -9,7 +9,7 @@ import './TaskList.css' const API_URL = '/api/wishlist' function WishlistDetail({ wishlistId, onNavigate, onRefresh }) { - const { authFetch } = useAuth() + const { authFetch, user } = useAuth() const [wishlistItem, setWishlistItem] = useState(null) const [loading, setLoading] = useState(true) const [loadingWishlist, setLoadingWishlist] = useState(true) @@ -404,7 +404,7 @@ function WishlistDetail({ wishlistId, onNavigate, onRefresh }) { {/* Связанная задача или кнопки действий */} {wishlistItem.unlocked && !wishlistItem.completed && ( <> - {wishlistItem.linked_task ? ( + {wishlistItem.linked_task && wishlistItem.linked_task.user_id === user?.id ? (
Связанная задача:
{ resetForm() - onNavigate?.('wishlist') + // Возвращаемся на доску, если она была указана + if (boardId) { + onNavigate?.('wishlist', { boardId }) + } else { + onNavigate?.('wishlist') + } } if (loadingWishlist) {