Реализована возможность изменения проектов
- Добавлено поле deleted в таблицу projects (миграция 007) - Изменена иконка перехода на экран проектов (список вместо звезды) - Заменен крестик на троеточие в списке проектов - Добавлено модальное окно с кнопками 'Перенести' и 'Удалить' - Реализован экран переноса проекта с выбором существующего или созданием нового - Добавлены API endpoints: /project/move и /project/delete - При переносе проекта обновляются nodes и weekly_goals с обработкой конфликтов - При удалении проекта удаляются все связанные weekly_goals - Добавлена фильтрация удаленных проектов во всех SQL запросах - Обновлена materialized view для исключения удаленных проектов
This commit is contained in:
@@ -1832,6 +1832,23 @@ func (a *App) initPlayLifeDB() error {
|
||||
return fmt.Errorf("failed to create projects table: %w", err)
|
||||
}
|
||||
|
||||
// Добавляем колонку deleted, если её нет (для существующих баз)
|
||||
alterProjectsTable := `
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
`
|
||||
if _, err := a.DB.Exec(alterProjectsTable); err != nil {
|
||||
log.Printf("Warning: Failed to add deleted column to projects table: %v", err)
|
||||
}
|
||||
|
||||
// Создаем индекс на deleted
|
||||
createProjectsDeletedIndex := `
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_deleted ON projects(deleted)
|
||||
`
|
||||
if _, err := a.DB.Exec(createProjectsDeletedIndex); err != nil {
|
||||
log.Printf("Warning: Failed to create projects deleted index: %v", err)
|
||||
}
|
||||
|
||||
if _, err := a.DB.Exec(createEntriesTable); err != nil {
|
||||
return fmt.Errorf("failed to create entries table: %w", err)
|
||||
}
|
||||
@@ -1882,6 +1899,8 @@ func (a *App) initPlayLifeDB() error {
|
||||
1, 2, 3
|
||||
) agg
|
||||
ON p.id = agg.project_id
|
||||
WHERE
|
||||
p.deleted = FALSE
|
||||
ORDER BY
|
||||
p.id, agg.report_year, agg.report_week
|
||||
`
|
||||
@@ -1971,6 +1990,7 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) {
|
||||
-- Фильтруем ТОЛЬКО по целям текущего года и недели
|
||||
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||||
AND p.deleted = FALSE
|
||||
ORDER BY
|
||||
total_score DESC
|
||||
`
|
||||
@@ -2339,6 +2359,8 @@ func main() {
|
||||
r.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS")
|
||||
r.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS")
|
||||
r.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS")
|
||||
r.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS")
|
||||
r.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS")
|
||||
r.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS")
|
||||
r.HandleFunc("/admin", app.adminHandler).Methods("GET")
|
||||
r.HandleFunc("/admin.html", app.adminHandler).Methods("GET")
|
||||
@@ -2794,10 +2816,10 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr
|
||||
// Вставляем проекты
|
||||
for projectName := range projectNames {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO projects (name)
|
||||
VALUES ($1)
|
||||
INSERT INTO projects (name, deleted)
|
||||
VALUES ($1, FALSE)
|
||||
ON CONFLICT (name) DO UPDATE
|
||||
SET name = EXCLUDED.name
|
||||
SET name = EXCLUDED.name, deleted = FALSE
|
||||
`, projectName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upsert project %s: %w", projectName, err)
|
||||
@@ -2818,10 +2840,10 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr
|
||||
// 3. Вставляем nodes
|
||||
for _, node := range nodes {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO nodes (project_id, entry_id, score)
|
||||
SELECT p.id, $1, $2
|
||||
FROM projects p
|
||||
WHERE p.name = $3
|
||||
INSERT INTO nodes (project_id, entry_id, score)
|
||||
SELECT p.id, $1, $2
|
||||
FROM projects p
|
||||
WHERE p.name = $3 AND p.deleted = FALSE
|
||||
`, entryID, node.Score, node.Project)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert node for project %s: %w", node.Project, err)
|
||||
@@ -2892,6 +2914,7 @@ func (a *App) setupWeeklyGoals() error {
|
||||
FROM projects p
|
||||
CROSS JOIN current_info ci
|
||||
LEFT JOIN goal_metrics gm ON p.id = gm.project_id
|
||||
WHERE p.deleted = FALSE
|
||||
ON CONFLICT (project_id, goal_year, goal_week) DO UPDATE
|
||||
SET
|
||||
min_goal_score = EXCLUDED.min_goal_score,
|
||||
@@ -2931,6 +2954,7 @@ func (a *App) sendWeeklyGoalsTelegramMessage() error {
|
||||
WHERE
|
||||
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||||
AND p.deleted = FALSE
|
||||
ORDER BY
|
||||
p.name
|
||||
`
|
||||
@@ -3056,6 +3080,7 @@ func (a *App) weeklyGoalsSetupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
WHERE
|
||||
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||||
AND p.deleted = FALSE
|
||||
ORDER BY
|
||||
p.name
|
||||
`
|
||||
@@ -3192,6 +3217,8 @@ func (a *App) recreateMaterializedViewHandler(w http.ResponseWriter, r *http.Req
|
||||
1, 2, 3
|
||||
) agg
|
||||
ON p.id = agg.project_id
|
||||
WHERE
|
||||
p.deleted = FALSE
|
||||
ORDER BY
|
||||
p.id, agg.report_year, agg.report_week
|
||||
`
|
||||
@@ -3232,6 +3259,8 @@ func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
priority
|
||||
FROM
|
||||
projects
|
||||
WHERE
|
||||
deleted = FALSE
|
||||
ORDER BY
|
||||
priority ASC NULLS LAST,
|
||||
project_name
|
||||
@@ -3442,6 +3471,214 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
type ProjectMoveRequest struct {
|
||||
ID int `json:"id"`
|
||||
NewName string `json:"new_name"`
|
||||
}
|
||||
|
||||
type ProjectDeleteRequest struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
var req ProjectMoveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Printf("Error decoding move project request: %v", err)
|
||||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.NewName == "" {
|
||||
sendErrorWithCORS(w, "new_name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Начинаем транзакцию
|
||||
tx, err := a.DB.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Error beginning transaction: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Ищем проект с таким именем
|
||||
var targetProjectID int
|
||||
err = tx.QueryRow(`
|
||||
SELECT id FROM projects WHERE name = $1 AND deleted = FALSE
|
||||
`, req.NewName).Scan(&targetProjectID)
|
||||
|
||||
var finalProjectID int
|
||||
if err == sql.ErrNoRows {
|
||||
// Проект не найден - создаем новый
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO projects (name, deleted)
|
||||
VALUES ($1, FALSE)
|
||||
RETURNING id
|
||||
`, req.NewName).Scan(&finalProjectID)
|
||||
if err != nil {
|
||||
log.Printf("Error creating new project: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error creating new project: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Printf("Error querying target project: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error querying target project: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
// Проект найден - используем его ID
|
||||
finalProjectID = targetProjectID
|
||||
}
|
||||
|
||||
// Обновляем все nodes с project_id на целевой
|
||||
_, err = tx.Exec(`
|
||||
UPDATE nodes
|
||||
SET project_id = $1
|
||||
WHERE project_id = $2
|
||||
`, finalProjectID, req.ID)
|
||||
if err != nil {
|
||||
log.Printf("Error updating nodes: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error updating nodes: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем weekly_goals
|
||||
// Сначала удаляем записи старого проекта, которые конфликтуют с записями целевого проекта
|
||||
// (если у целевого проекта уже есть запись для той же недели)
|
||||
_, err = tx.Exec(`
|
||||
DELETE FROM weekly_goals
|
||||
WHERE project_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM weekly_goals wg2
|
||||
WHERE wg2.project_id = $2
|
||||
AND wg2.goal_year = weekly_goals.goal_year
|
||||
AND wg2.goal_week = weekly_goals.goal_week
|
||||
)
|
||||
`, req.ID, finalProjectID)
|
||||
if err != nil {
|
||||
log.Printf("Error deleting conflicting weekly_goals: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error deleting conflicting weekly_goals: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Теперь обновляем оставшиеся записи (те, которые не конфликтуют)
|
||||
_, err = tx.Exec(`
|
||||
UPDATE weekly_goals
|
||||
SET project_id = $1
|
||||
WHERE project_id = $2
|
||||
`, finalProjectID, req.ID)
|
||||
if err != nil {
|
||||
log.Printf("Error updating weekly_goals: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error updating weekly_goals: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Помечаем старый проект как удаленный
|
||||
_, err = tx.Exec(`
|
||||
UPDATE projects
|
||||
SET deleted = TRUE
|
||||
WHERE id = $1
|
||||
`, req.ID)
|
||||
if err != nil {
|
||||
log.Printf("Error marking project as deleted: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error marking project as deleted: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Коммитим транзакцию
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем materialized view
|
||||
_, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to refresh materialized view: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Project moved successfully",
|
||||
"project_id": finalProjectID,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
setCORSHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
setCORSHeaders(w)
|
||||
|
||||
var req ProjectDeleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
log.Printf("Error decoding delete project request: %v", err)
|
||||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Начинаем транзакцию
|
||||
tx, err := a.DB.Begin()
|
||||
if err != nil {
|
||||
log.Printf("Error beginning transaction: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Удаляем все записи weekly_goals для этого проекта
|
||||
_, err = tx.Exec(`
|
||||
DELETE FROM weekly_goals
|
||||
WHERE project_id = $1
|
||||
`, req.ID)
|
||||
if err != nil {
|
||||
log.Printf("Error deleting weekly_goals: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error deleting weekly_goals: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Помечаем проект как удаленный
|
||||
_, err = tx.Exec(`
|
||||
UPDATE projects
|
||||
SET deleted = TRUE
|
||||
WHERE id = $1
|
||||
`, req.ID)
|
||||
if err != nil {
|
||||
log.Printf("Error marking project as deleted: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error marking project as deleted: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Коммитим транзакцию
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем materialized view
|
||||
_, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv")
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to refresh materialized view: %v", err)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Project deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Логирование входящего запроса
|
||||
log.Printf("=== Todoist Webhook Request ===")
|
||||
@@ -3700,7 +3937,9 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
JOIN
|
||||
projects p
|
||||
-- Присоединяем имя проекта, используя ID из той таблицы, где он не NULL
|
||||
ON p.id = COALESCE(wr.project_id, wg.project_id)
|
||||
ON p.id = COALESCE(wr.project_id, wg.project_id)
|
||||
WHERE
|
||||
p.deleted = FALSE
|
||||
ORDER BY
|
||||
report_year DESC,
|
||||
report_week DESC,
|
||||
|
||||
Reference in New Issue
Block a user