4.25.0: Группы вместо проектов для задач и желаний
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m31s

This commit is contained in:
poignatov
2026-02-06 17:42:36 +03:00
parent 0275d9aecf
commit 9f37d8b518
13 changed files with 888 additions and 188 deletions

View File

@@ -330,6 +330,7 @@ type Task struct {
Position *int `json:"position,omitempty"` // Position for subtasks
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
ProjectNames []string `json:"project_names"`
GroupName *string `json:"group_name,omitempty"` // Название группы задачи
SubtasksCount int `json:"subtasks_count"`
HasProgression bool `json:"has_progression"`
AutoComplete bool `json:"auto_complete"`
@@ -391,6 +392,7 @@ type TaskRequest struct {
RepetitionDate *string `json:"repetition_date,omitempty"`
WishlistID *int `json:"wishlist_id,omitempty"`
RewardPolicy *string `json:"reward_policy,omitempty"` // "personal" или "general" для задач, связанных с желаниями
GroupName *string `json:"group_name,omitempty"` // Название группы задачи
Rewards []RewardRequest `json:"rewards,omitempty"`
Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
// Test-specific fields
@@ -464,9 +466,8 @@ type WishlistItem struct {
LockedConditionsCount int `json:"locked_conditions_count,omitempty"` // Общее количество заблокированных условий
UnlockConditions []UnlockConditionDisplay `json:"unlock_conditions,omitempty"`
LinkedTask *LinkedTask `json:"linked_task,omitempty"`
TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания
ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание
ProjectName *string `json:"project_name,omitempty"` // Название проекта
TasksCount int `json:"tasks_count,omitempty"` // Количество задач для этого желания
GroupName *string `json:"group_name,omitempty"` // Название группы желания
}
type UnlockConditionDisplay struct {
@@ -494,7 +495,7 @@ type WishlistRequest struct {
Name string `json:"name"`
Price *float64 `json:"price,omitempty"`
Link *string `json:"link,omitempty"`
ProjectID *int `json:"project_id,omitempty"` // ID проекта, к которому принадлежит желание
GroupName *string `json:"group_name,omitempty"` // Название группы желания
UnlockConditions []UnlockConditionRequest `json:"unlock_conditions,omitempty"`
}
@@ -4227,6 +4228,9 @@ func main() {
protected.HandleFunc("/api/wishlist/{id}/uncomplete", app.uncompleteWishlistHandler).Methods("POST", "OPTIONS")
protected.HandleFunc("/api/wishlist/{id}/copy", app.copyWishlistHandler).Methods("POST", "OPTIONS")
// Group suggestions
protected.HandleFunc("/api/group-suggestions", app.getGroupSuggestionsHandler).Methods("GET", "OPTIONS")
// Admin operations
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
@@ -5868,6 +5872,11 @@ func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Обновляем MV для групповых саджестов (имя проекта изменилось)
if err := a.refreshGroupSuggestionsMV(); err != nil {
log.Printf("Warning: Failed to refresh group suggestions MV: %v", err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Project renamed successfully",
@@ -5949,8 +5958,10 @@ func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) {
return
}
// MV обновляется только по крону в понедельник в 6:00 утра
// Данные текущей недели берутся напрямую из nodes
// Обновляем MV для групповых саджестов (проект переименован или удалён)
if err := a.refreshGroupSuggestionsMV(); err != nil {
log.Printf("Warning: Failed to refresh group suggestions MV: %v", err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
@@ -6027,8 +6038,10 @@ func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) {
return
}
// MV обновляется только по крону в понедельник в 6:00 утра
// Данные текущей недели берутся напрямую из nodes
// Обновляем MV для групповых саджестов (проект удалён)
if err := a.refreshGroupSuggestionsMV(); err != nil {
log.Printf("Warning: Failed to refresh group suggestions MV: %v", err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
@@ -6094,6 +6107,11 @@ func (a *App) createProjectHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Обновляем MV для групповых саджестов (проекты попадают в саджесты)
if err := a.refreshGroupSuggestionsMV(); err != nil {
log.Printf("Warning: Failed to refresh group suggestions MV: %v", err)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "Project created successfully",
@@ -7333,6 +7351,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
t.wishlist_id,
t.config_id,
t.reward_policy,
t.group_name,
COALESCE((
SELECT COUNT(*)
FROM tasks st
@@ -7389,6 +7408,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
var wishlistID sql.NullInt64
var configID sql.NullInt64
var rewardPolicy sql.NullString
var groupName sql.NullString
var projectNames pq.StringArray
var subtaskProjectNames pq.StringArray
var autoComplete bool
@@ -7405,6 +7425,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
&wishlistID,
&configID,
&rewardPolicy,
&groupName,
&task.SubtasksCount,
&projectNames,
&subtaskProjectNames,
@@ -7444,6 +7465,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
if rewardPolicy.Valid {
task.RewardPolicy = &rewardPolicy.String
}
if groupName.Valid && groupName.String != "" {
groupNameVal := groupName.String
task.GroupName = &groupNameVal
}
task.AutoComplete = autoComplete
// Объединяем проекты из основной задачи и подзадач
@@ -7504,6 +7529,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
var wishlistID sql.NullInt64
var configID sql.NullInt64
var rewardPolicy sql.NullString
var groupName sql.NullString
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
var repetitionPeriodStr string
@@ -7514,11 +7540,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
COALESCE(repetition_date, '') as repetition_date,
wishlist_id,
config_id,
reward_policy
reward_policy,
group_name
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, &rewardPolicy,
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID, &rewardPolicy, &groupName,
)
log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
@@ -7578,6 +7605,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
if rewardPolicy.Valid {
task.RewardPolicy = &rewardPolicy.String
}
if groupName.Valid && groupName.String != "" {
groupNameVal := groupName.String
task.GroupName = &groupNameVal
}
// Получаем награды основной задачи
rewards := make([]Reward, 0)
@@ -8111,11 +8142,11 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
now := time.Now().In(loc)
insertSQL = `
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)
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy, group_name)
VALUES ($1, $2, $3, $4, $5::INTERVAL, NULL, $6, 0, FALSE, $7, $8, $9)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now, wishlistIDValue, rewardPolicyValue}
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriodValue, now, wishlistIDValue, rewardPolicyValue, req.GroupName}
} else if repetitionDate.Valid {
// Вычисляем next_show_at для задачи с repetition_date
@@ -8130,26 +8161,26 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc))
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, 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, rewardPolicyValue}
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, next_show_at, completed, deleted, wishlist_id, reward_policy, group_name)
VALUES ($1, $2, $3, $4, NULL, $5, $6, 0, FALSE, $7, $8, $9)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, wishlistIDValue, rewardPolicyValue, req.GroupName}
} else {
insertSQL = `
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, rewardPolicyValue}
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy, group_name)
VALUES ($1, $2, $3, $4, NULL, $5, 0, FALSE, $6, $7, $8)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, wishlistIDValue, rewardPolicyValue, req.GroupName}
}
} else {
insertSQL = `
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)
INSERT INTO tasks (user_id, name, reward_message, progression_base, repetition_period, repetition_date, completed, deleted, wishlist_id, reward_policy, group_name)
VALUES ($1, $2, $3, $4, NULL, NULL, 0, FALSE, $5, $6, $7)
RETURNING id
`
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, wishlistIDValue, rewardPolicyValue}
insertArgs = []interface{}{userID, strings.TrimSpace(req.Name), rewardMessage, progressionBase, wishlistIDValue, rewardPolicyValue, req.GroupName}
}
err = tx.QueryRow(insertSQL, insertArgs...).Scan(&taskID)
@@ -8316,6 +8347,13 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Обновляем MV для групповых саджестов
if req.GroupName != nil && *req.GroupName != "" {
if err := a.refreshGroupSuggestionsMV(); err != nil {
log.Printf("Warning: Failed to refresh group suggestions MV: %v", err)
}
}
// Возвращаем созданную задачу
var createdTask Task
var lastCompletedAt sql.NullString
@@ -8530,35 +8568,35 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
now := time.Now().In(loc)
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, reward_policy = $7
WHERE id = $8
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, group_name = $8
WHERE id = $9
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, newWishlistID, rewardPolicyValue, taskID}
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionPeriod.String, now, newWishlistID, rewardPolicyValue, req.GroupName, taskID}
} else if repetitionDate.Valid {
// Вычисляем next_show_at для задачи с repetition_date
nextShowAt := calculateNextShowAtFromRepetitionDate(repetitionDate.String, time.Now().In(loc))
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, reward_policy = $7
WHERE id = $8
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, newWishlistID, rewardPolicyValue, taskID}
UPDATE tasks
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, group_name = $8
WHERE id = $9
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, nextShowAt, newWishlistID, rewardPolicyValue, req.GroupName, taskID}
} else {
updateSQL = `
UPDATE tasks
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, rewardPolicyValue, taskID}
UPDATE tasks
SET name = $1, reward_message = $2, progression_base = $3, repetition_period = NULL, repetition_date = $4, wishlist_id = $5, reward_policy = $6, group_name = $7
WHERE id = $8
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, repetitionDate.String, newWishlistID, rewardPolicyValue, req.GroupName, 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, reward_policy = $5
WHERE id = $6
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, group_name = $6
WHERE id = $7
`
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, newWishlistID, rewardPolicyValue, taskID}
updateArgs = []interface{}{strings.TrimSpace(req.Name), rewardMessage, progressionBase, newWishlistID, rewardPolicyValue, req.GroupName, taskID}
}
_, err = tx.Exec(updateSQL, updateArgs...)
@@ -8877,6 +8915,13 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Обновляем MV для групповых саджестов
if req.GroupName != nil && *req.GroupName != "" {
if err := a.refreshGroupSuggestionsMV(); err != nil {
log.Printf("Warning: Failed to refresh group suggestions MV: %v", err)
}
}
// Возвращаем обновленную задачу
var updatedTask Task
var lastCompletedAt sql.NullString
@@ -10347,8 +10392,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
wi.image_path,
wi.link,
wi.completed,
wi.project_id AS item_project_id,
wp.name AS item_project_name,
wi.group_name,
wc.id AS condition_id,
wc.display_order,
wc.task_condition_id,
@@ -10361,7 +10405,6 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
sc.required_points,
sc.start_date
FROM wishlist_items wi
LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE
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 AND t.deleted = FALSE
@@ -10388,8 +10431,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
var price sql.NullFloat64
var imagePath, link sql.NullString
var completed bool
var itemProjectID sql.NullInt64
var itemProjectName sql.NullString
var groupName sql.NullString
var conditionID, displayOrder sql.NullInt64
var taskConditionID, scoreConditionID sql.NullInt64
@@ -10403,7 +10445,7 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
err := rows.Scan(
&itemID, &name, &price, &imagePath, &link, &completed,
&itemProjectID, &itemProjectName,
&groupName,
&conditionID, &displayOrder,
&taskConditionID, &scoreConditionID, &conditionUserID,
&taskID, &taskName,
@@ -10434,13 +10476,9 @@ func (a *App) getWishlistItemsWithConditions(userID int, includeCompleted bool)
l := link.String
item.Link = &l
}
if itemProjectID.Valid {
projectIDVal := int(itemProjectID.Int64)
item.ProjectID = &projectIDVal
}
if itemProjectName.Valid {
projectNameVal := itemProjectName.String
item.ProjectName = &projectNameVal
if groupName.Valid && groupName.String != "" {
groupNameVal := groupName.String
item.GroupName = &groupNameVal
}
itemsMap[itemID] = item
}
@@ -11108,10 +11146,10 @@ func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) {
var wishlistID int
err = tx.QueryRow(`
INSERT INTO wishlist_items (user_id, author_id, name, price, link, project_id, completed, deleted)
INSERT INTO wishlist_items (user_id, author_id, name, price, link, group_name, completed, deleted)
VALUES ($1, $1, $2, $3, $4, $5, FALSE, FALSE)
RETURNING id
`, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID).Scan(&wishlistID)
`, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.GroupName).Scan(&wishlistID)
if err != nil {
log.Printf("Error creating wishlist item: %v", err)
@@ -11141,6 +11179,13 @@ func (a *App) createWishlistHandler(w http.ResponseWriter, r *http.Request) {
}
log.Printf("createWishlistHandler: transaction committed successfully")
// Обновляем MV для групповых саджестов
if req.GroupName != nil && *req.GroupName != "" {
if err := a.refreshGroupSuggestionsMV(); err != nil {
log.Printf("Warning: Failed to refresh group suggestions MV: %v", err)
}
}
// Получаем созданное желание с условиями
items, err := a.getWishlistItemsWithConditions(userID, false)
if err != nil {
@@ -11322,8 +11367,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
wi.image_path,
wi.link,
wi.completed,
wi.project_id AS item_project_id,
wp.name AS item_project_name,
wi.group_name,
wc.id AS condition_id,
wc.display_order,
wc.task_condition_id,
@@ -11337,7 +11381,6 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
sc.required_points,
sc.start_date
FROM wishlist_items wi
LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE
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 AND t.deleted = FALSE
@@ -11364,8 +11407,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
var imagePath sql.NullString
var link sql.NullString
var completed bool
var itemProjectID sql.NullInt64
var itemProjectName sql.NullString
var groupName sql.NullString
var conditionID sql.NullInt64
var displayOrder sql.NullInt64
var taskConditionID sql.NullInt64
@@ -11380,7 +11422,7 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
var startDate sql.NullTime
err := rows.Scan(
&itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName,
&itemID, &name, &price, &imagePath, &link, &completed, &groupName,
&conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID,
&taskID, &taskName, &taskNextShowAt, &projectID, &projectName, &requiredPoints, &startDate,
)
@@ -11410,13 +11452,9 @@ func (a *App) getWishlistItemHandler(w http.ResponseWriter, r *http.Request) {
if link.Valid {
item.Link = &link.String
}
if itemProjectID.Valid {
projectIDVal := int(itemProjectID.Int64)
item.ProjectID = &projectIDVal
}
if itemProjectName.Valid {
projectNameVal := itemProjectName.String
item.ProjectName = &projectNameVal
if groupName.Valid && groupName.String != "" {
groupNameVal := groupName.String
item.GroupName = &groupNameVal
}
itemsMap[itemID] = item
}
@@ -11679,9 +11717,9 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
// Обновляем желание (не проверяем user_id в WHERE, так как доступ уже проверен выше)
_, err = tx.Exec(`
UPDATE wishlist_items
SET name = $1, price = $2, link = $3, project_id = $4, updated_at = NOW()
SET name = $1, price = $2, link = $3, group_name = $4, updated_at = NOW()
WHERE id = $5
`, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID, itemID)
`, strings.TrimSpace(req.Name), req.Price, req.Link, req.GroupName, itemID)
if err != nil {
log.Printf("Error updating wishlist item: %v", err)
@@ -11703,6 +11741,13 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Обновляем MV для групповых саджестов
if req.GroupName != nil && *req.GroupName != "" {
if err := a.refreshGroupSuggestionsMV(); err != nil {
log.Printf("Warning: Failed to refresh group suggestions MV: %v", err)
}
}
// Получаем обновлённое желание через getWishlistItemHandler логику
// Используем тот же запрос, что и в getWishlistItemHandler
query := `
@@ -11713,6 +11758,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
wi.image_path,
wi.link,
wi.completed,
wi.group_name,
wc.id AS condition_id,
wc.display_order,
wc.task_condition_id,
@@ -11752,6 +11798,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
var imagePath sql.NullString
var link sql.NullString
var completed bool
var groupName sql.NullString
var conditionID sql.NullInt64
var displayOrder sql.NullInt64
var taskConditionID sql.NullInt64
@@ -11765,7 +11812,7 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
var startDate sql.NullTime
err := rows.Scan(
&itemID, &name, &price, &imagePath, &link, &completed,
&itemID, &name, &price, &imagePath, &link, &completed, &groupName,
&conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID,
&taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate,
)
@@ -11802,6 +11849,10 @@ func (a *App) updateWishlistHandler(w http.ResponseWriter, r *http.Request) {
if link.Valid {
item.Link = &link.String
}
if groupName.Valid && groupName.String != "" {
groupNameVal := groupName.String
item.GroupName = &groupNameVal
}
itemsMap[itemID] = item
}
@@ -12416,11 +12467,12 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) {
var ownerID int
var boardID sql.NullInt64
var authorID sql.NullInt64
var groupName sql.NullString
err = a.DB.QueryRow(`
SELECT user_id, name, price, link, image_path, board_id, author_id
SELECT user_id, name, price, link, image_path, board_id, author_id, group_name
FROM wishlist_items
WHERE id = $1 AND deleted = FALSE
`, itemID).Scan(&ownerID, &name, &price, &link, &imagePath, &boardID, &authorID)
`, itemID).Scan(&ownerID, &name, &price, &link, &imagePath, &boardID, &authorID, &groupName)
if err == sql.ErrNoRows || ownerID != userID {
sendErrorWithCORS(w, "Wishlist item not found", http.StatusNotFound)
@@ -12512,7 +12564,7 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) {
}
// Определяем значения для board_id и author_id
var boardIDVal, authorIDVal interface{}
var boardIDVal, authorIDVal, groupNameVal interface{}
if boardID.Valid {
boardIDVal = int(boardID.Int64)
}
@@ -12522,12 +12574,15 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) {
// Если author_id не был установлен, используем текущего пользователя
authorIDVal = userID
}
if groupName.Valid {
groupNameVal = groupName.String
}
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)
INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, group_name, completed, deleted)
VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, FALSE)
RETURNING id
`, ownerID, boardIDVal, authorIDVal, name+" (копия)", priceVal, linkVal).Scan(&newWishlistID)
`, ownerID, boardIDVal, authorIDVal, name+" (копия)", priceVal, linkVal, groupNameVal).Scan(&newWishlistID)
if err != nil {
log.Printf("Error creating wishlist copy: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error creating wishlist copy: %v", err), http.StatusInternalServerError)
@@ -12615,6 +12670,13 @@ func (a *App) copyWishlistHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Обновляем MV для групповых саджестов
if groupName.Valid && groupName.String != "" {
if err := a.refreshGroupSuggestionsMV(); err != nil {
log.Printf("Warning: Failed to refresh group suggestions MV: %v", err)
}
}
// Получаем созданное желание с условиями
items, err := a.getWishlistItemsWithConditions(userID, false)
if err != nil {
@@ -13844,8 +13906,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
wi.image_path,
wi.link,
wi.completed,
wi.project_id AS item_project_id,
wp.name AS item_project_name,
wi.group_name,
COALESCE(wi.author_id, wi.user_id) AS item_owner_id,
wc.id AS condition_id,
wc.display_order,
@@ -13859,7 +13920,6 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
sc.required_points,
sc.start_date
FROM wishlist_items wi
LEFT JOIN projects wp ON wi.project_id = wp.id AND wp.deleted = FALSE
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 AND t.deleted = FALSE
@@ -13886,8 +13946,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
var imagePath sql.NullString
var link sql.NullString
var completed bool
var itemProjectID sql.NullInt64
var itemProjectName sql.NullString
var groupName sql.NullString
var itemOwnerID sql.NullInt64
var conditionID sql.NullInt64
var displayOrder sql.NullInt64
@@ -13902,7 +13961,7 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
var startDate sql.NullTime
err := rows.Scan(
&itemID, &name, &price, &imagePath, &link, &completed, &itemProjectID, &itemProjectName, &itemOwnerID,
&itemID, &name, &price, &imagePath, &link, &completed, &groupName, &itemOwnerID,
&conditionID, &displayOrder, &taskConditionID, &scoreConditionID, &conditionUserID,
&taskID, &taskName, &projectID, &projectName, &requiredPoints, &startDate,
)
@@ -13932,13 +13991,9 @@ func (a *App) getWishlistItemsByBoard(boardID int, userID int) ([]WishlistItem,
if link.Valid {
item.Link = &link.String
}
if itemProjectID.Valid {
projectIDVal := int(itemProjectID.Int64)
item.ProjectID = &projectIDVal
}
if itemProjectName.Valid {
projectNameVal := itemProjectName.String
item.ProjectName = &projectNameVal
if groupName.Valid && groupName.String != "" {
groupNameVal := groupName.String
item.GroupName = &groupNameVal
}
itemsMap[itemID] = item
}
@@ -14211,10 +14266,10 @@ func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) {
var itemID int
err = tx.QueryRow(`
INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, project_id, completed, deleted)
INSERT INTO wishlist_items (user_id, board_id, author_id, name, price, link, group_name, completed, deleted)
VALUES ($1, $2, $3, $4, $5, $6, $7, FALSE, FALSE)
RETURNING id
`, ownerID, boardID, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.ProjectID).Scan(&itemID)
`, ownerID, boardID, userID, strings.TrimSpace(req.Name), req.Price, req.Link, req.GroupName).Scan(&itemID)
if err != nil {
log.Printf("createBoardItemHandler: Error creating board item: %v", err)
@@ -14244,6 +14299,13 @@ func (a *App) createBoardItemHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Обновляем MV для групповых саджестов
if req.GroupName != nil && *req.GroupName != "" {
if err := a.refreshGroupSuggestionsMV(); err != nil {
log.Printf("Warning: Failed to refresh group suggestions MV: %v", err)
}
}
// Возвращаем созданное желание
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
@@ -15521,3 +15583,57 @@ func decodeHTMLEntities(s string) string {
}
return s
}
// refreshGroupSuggestionsMV обновляет materialized view для групповых саджестов
func (a *App) refreshGroupSuggestionsMV() error {
_, err := a.DB.Exec("REFRESH MATERIALIZED VIEW CONCURRENTLY user_group_suggestions_mv")
if err != nil {
log.Printf("Error refreshing user_group_suggestions_mv: %v", err)
return err
}
return nil
}
// getGroupSuggestionsHandler возвращает список уникальных имён групп для текущего пользователя
func (a *App) getGroupSuggestionsHandler(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
}
query := `
SELECT DISTINCT group_name
FROM user_group_suggestions_mv
WHERE user_id = $1
ORDER BY group_name
`
rows, err := a.DB.Query(query, userID)
if err != nil {
log.Printf("Error querying group suggestions: %v", err)
sendErrorWithCORS(w, fmt.Sprintf("Error querying group suggestions: %v", err), http.StatusInternalServerError)
return
}
defer rows.Close()
groups := make([]string, 0)
for rows.Next() {
var groupName string
if err := rows.Scan(&groupName); err != nil {
log.Printf("Error scanning group name: %v", err)
continue
}
groups = append(groups, groupName)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(groups)
}

View File

@@ -0,0 +1,36 @@
-- Migration: Remove group_name field from wishlist_items and tasks tables
-- Date: 2026-02-XX
--
-- This migration reverses the changes made in 000014_add_group_name.up.sql
-- Step 1: Drop materialized view
DROP MATERIALIZED VIEW IF EXISTS user_group_suggestions_mv;
-- Step 2: Drop indexes on group_name
DROP INDEX IF EXISTS idx_tasks_group_name;
DROP INDEX IF EXISTS idx_wishlist_items_group_name;
-- Step 3: Remove group_name from tasks
ALTER TABLE tasks
DROP COLUMN group_name;
-- Step 4: Add back project_id to wishlist_items
ALTER TABLE wishlist_items
ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL;
-- Step 5: Try to restore project_id from group_name (if possible)
-- Note: This is best-effort, as group_name might not match project names exactly
UPDATE wishlist_items wi
SET project_id = p.id
FROM projects p
WHERE wi.group_name = p.name
AND wi.group_name IS NOT NULL
AND wi.group_name != ''
AND p.deleted = FALSE;
-- Step 6: Create index on project_id
CREATE INDEX idx_wishlist_items_project_id ON wishlist_items(project_id);
-- Step 7: Remove group_name from wishlist_items
ALTER TABLE wishlist_items
DROP COLUMN group_name;

View File

@@ -0,0 +1,60 @@
-- Migration: Add group_name field to wishlist_items and tasks tables
-- Date: 2026-02-XX
--
-- This migration:
-- 1. Adds group_name field to wishlist_items (replacing project_id)
-- 2. Migrates existing data from project_id to group_name
-- 3. Removes project_id column from wishlist_items
-- 4. Adds group_name field to tasks
-- 5. Creates materialized view for group suggestions
-- Step 1: Add group_name to wishlist_items
ALTER TABLE wishlist_items
ADD COLUMN group_name VARCHAR(255);
-- Step 2: Migrate existing data from project_id to group_name
UPDATE wishlist_items wi
SET group_name = p.name
FROM projects p
WHERE wi.project_id = p.id AND wi.project_id IS NOT NULL;
-- Step 3: Remove project_id column and its index
DROP INDEX IF EXISTS idx_wishlist_items_project_id;
ALTER TABLE wishlist_items
DROP COLUMN project_id;
-- Step 4: Add group_name to tasks
ALTER TABLE tasks
ADD COLUMN group_name VARCHAR(255);
-- Step 5: Create indexes on group_name
CREATE INDEX idx_wishlist_items_group_name ON wishlist_items(group_name) WHERE group_name IS NOT NULL;
CREATE INDEX idx_tasks_group_name ON tasks(group_name) WHERE group_name IS NOT NULL;
-- Step 6: Create materialized view for group suggestions
CREATE MATERIALIZED VIEW user_group_suggestions_mv AS
SELECT DISTINCT user_id, group_name FROM (
-- Желания пользователя (собственные)
SELECT wi.user_id, wi.group_name FROM wishlist_items wi
WHERE wi.deleted = FALSE AND wi.group_name IS NOT NULL AND wi.group_name != ''
UNION
-- Желания с досок, на которых пользователь участник
SELECT wbm.user_id, wi.group_name FROM wishlist_items wi
JOIN wishlist_board_members wbm ON wi.board_id = wbm.board_id
WHERE wi.deleted = FALSE AND wi.group_name IS NOT NULL AND wi.group_name != ''
UNION
-- Задачи пользователя
SELECT t.user_id, t.group_name FROM tasks t
WHERE t.deleted = FALSE AND t.group_name IS NOT NULL AND t.group_name != ''
UNION
-- Имена проектов пользователя
SELECT p.user_id, p.name FROM projects p
WHERE p.deleted = FALSE
) sub;
-- Step 7: Create unique index for CONCURRENT refresh
CREATE UNIQUE INDEX idx_user_group_suggestions_mv_user_group ON user_group_suggestions_mv(user_id, group_name);
COMMENT ON COLUMN wishlist_items.group_name IS 'Group name for wishlist item (free text, replaces project_id)';
COMMENT ON COLUMN tasks.group_name IS 'Group name for task (free text)';
COMMENT ON MATERIALIZED VIEW user_group_suggestions_mv IS 'Materialized view for group name suggestions per user';