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'] && (
+ Пользователь, открывший ссылку, сможет присоединиться к доске +
+Загрузка...
+{error}
+ +Для присоединения необходимо войти в аккаунт
+ +Пока никто не присоединился к доске
+Поделитесь ссылкой, чтобы пригласить участников
+