Рефакторинг тестов: интеграция с задачами
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 57s
This commit is contained in:
@@ -72,19 +72,15 @@ type TestProgressRequest struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
WordsCount int `json:"words_count"`
|
||||
MaxCards *int `json:"max_cards,omitempty"`
|
||||
TryMessage string `json:"try_message"`
|
||||
ID int `json:"id"`
|
||||
WordsCount int `json:"words_count"`
|
||||
MaxCards *int `json:"max_cards,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigRequest struct {
|
||||
Name string `json:"name"`
|
||||
WordsCount int `json:"words_count"`
|
||||
MaxCards *int `json:"max_cards,omitempty"`
|
||||
TryMessage string `json:"try_message"`
|
||||
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||||
WordsCount int `json:"words_count"`
|
||||
MaxCards *int `json:"max_cards,omitempty"`
|
||||
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||||
}
|
||||
|
||||
type Dictionary struct {
|
||||
@@ -214,6 +210,7 @@ type Task struct {
|
||||
RepetitionPeriod *string `json:"repetition_period,omitempty"`
|
||||
RepetitionDate *string `json:"repetition_date,omitempty"`
|
||||
WishlistID *int `json:"wishlist_id,omitempty"`
|
||||
ConfigID *int `json:"config_id,omitempty"`
|
||||
// Дополнительные поля для списка задач (без omitempty чтобы всегда передавались)
|
||||
ProjectNames []string `json:"project_names"`
|
||||
SubtasksCount int `json:"subtasks_count"`
|
||||
@@ -234,9 +231,13 @@ type Subtask struct {
|
||||
}
|
||||
|
||||
type TaskDetail struct {
|
||||
Task Task `json:"task"`
|
||||
Rewards []Reward `json:"rewards"`
|
||||
Subtasks []Subtask `json:"subtasks"`
|
||||
Task Task `json:"task"`
|
||||
Rewards []Reward `json:"rewards"`
|
||||
Subtasks []Subtask `json:"subtasks"`
|
||||
// Test-specific fields (only present if task has config_id)
|
||||
WordsCount *int `json:"words_count,omitempty"`
|
||||
MaxCards *int `json:"max_cards,omitempty"`
|
||||
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||||
}
|
||||
|
||||
type RewardRequest struct {
|
||||
@@ -262,6 +263,11 @@ type TaskRequest struct {
|
||||
WishlistID *int `json:"wishlist_id,omitempty"`
|
||||
Rewards []RewardRequest `json:"rewards,omitempty"`
|
||||
Subtasks []SubtaskRequest `json:"subtasks,omitempty"`
|
||||
// Test-specific fields
|
||||
IsTest bool `json:"is_test,omitempty"`
|
||||
WordsCount *int `json:"words_count,omitempty"`
|
||||
MaxCards *int `json:"max_cards,omitempty"`
|
||||
DictionaryIDs []int `json:"dictionary_ids,omitempty"`
|
||||
}
|
||||
|
||||
type CompleteTaskRequest struct {
|
||||
@@ -1671,47 +1677,9 @@ func (a *App) updateTestProgressHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// If config_id is provided, send webhook with try_message
|
||||
if req.ConfigID != nil {
|
||||
configID := *req.ConfigID
|
||||
|
||||
// Use mutex to prevent duplicate webhook sends
|
||||
a.webhookMutex.Lock()
|
||||
lastTime, exists := a.lastWebhookTime[configID]
|
||||
now := time.Now()
|
||||
|
||||
// Only send webhook if it hasn't been sent in the last 5 seconds for this config
|
||||
shouldSend := !exists || now.Sub(lastTime) > 5*time.Second
|
||||
|
||||
if shouldSend {
|
||||
a.lastWebhookTime[configID] = now
|
||||
}
|
||||
a.webhookMutex.Unlock()
|
||||
|
||||
if !shouldSend {
|
||||
log.Printf("Webhook skipped for config_id %d (sent recently)", configID)
|
||||
} else {
|
||||
var tryMessage sql.NullString
|
||||
err := a.DB.QueryRow("SELECT try_message FROM configs WHERE id = $1", configID).Scan(&tryMessage)
|
||||
if err == nil && tryMessage.Valid && tryMessage.String != "" {
|
||||
// Process message directly (backend always runs together with frontend)
|
||||
_, err := a.processMessage(tryMessage.String, &userID)
|
||||
if err != nil {
|
||||
log.Printf("Error processing message: %v", err)
|
||||
// Remove from map on error so it can be retried
|
||||
a.webhookMutex.Lock()
|
||||
delete(a.lastWebhookTime, configID)
|
||||
a.webhookMutex.Unlock()
|
||||
} else {
|
||||
log.Printf("Message processed successfully for config_id %d", configID)
|
||||
}
|
||||
} else if err != nil && err != sql.ErrNoRows {
|
||||
log.Printf("Error fetching config: %v", err)
|
||||
} else if err == nil && (!tryMessage.Valid || tryMessage.String == "") {
|
||||
log.Printf("Webhook skipped for config_id %d (try_message is empty)", configID)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: Reward message is now sent via completeTaskHandler when the test task is automatically completed.
|
||||
// The config_id is kept in the request for potential future use, but we no longer send messages here
|
||||
// to avoid duplicate messages (one from test completion, one from task completion).
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
@@ -1734,7 +1702,7 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, name, words_count, max_cards, try_message
|
||||
SELECT id, words_count, max_cards
|
||||
FROM configs
|
||||
WHERE user_id = $1
|
||||
ORDER BY id
|
||||
@@ -1753,10 +1721,8 @@ func (a *App) getConfigsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var maxCards sql.NullInt64
|
||||
err := rows.Scan(
|
||||
&config.ID,
|
||||
&config.Name,
|
||||
&config.WordsCount,
|
||||
&maxCards,
|
||||
&config.TryMessage,
|
||||
)
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -2076,7 +2042,7 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt
|
||||
|
||||
// Get configs
|
||||
configsQuery := `
|
||||
SELECT id, name, words_count, max_cards, try_message
|
||||
SELECT id, words_count, max_cards
|
||||
FROM configs
|
||||
WHERE user_id = $1
|
||||
ORDER BY id
|
||||
@@ -2095,10 +2061,8 @@ func (a *App) getTestConfigsAndDictionariesHandler(w http.ResponseWriter, r *htt
|
||||
var maxCards sql.NullInt64
|
||||
err := configsRows.Scan(
|
||||
&config.ID,
|
||||
&config.Name,
|
||||
&config.WordsCount,
|
||||
&maxCards,
|
||||
&config.TryMessage,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -2175,10 +2139,6 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.WordsCount <= 0 {
|
||||
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
|
||||
return
|
||||
@@ -2193,10 +2153,10 @@ func (a *App) addConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var id int
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO configs (name, words_count, max_cards, try_message, user_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO configs (words_count, max_cards, user_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, userID).Scan(&id)
|
||||
`, req.WordsCount, req.MaxCards, userID).Scan(&id)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -2268,10 +2228,6 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
sendErrorWithCORS(w, "Имя обязательно для заполнения", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.WordsCount <= 0 {
|
||||
sendErrorWithCORS(w, "Количество слов должно быть больше 0", http.StatusBadRequest)
|
||||
return
|
||||
@@ -2286,9 +2242,9 @@ func (a *App) updateConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := tx.Exec(`
|
||||
UPDATE configs
|
||||
SET name = $1, words_count = $2, max_cards = $3, try_message = $4
|
||||
WHERE id = $5
|
||||
`, req.Name, req.WordsCount, req.MaxCards, req.TryMessage, configID)
|
||||
SET words_count = $1, max_cards = $2
|
||||
WHERE id = $3
|
||||
`, req.WordsCount, req.MaxCards, configID)
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -2840,6 +2796,12 @@ func (a *App) initAuthDB() error {
|
||||
// Не возвращаем ошибку, чтобы приложение могло запуститься
|
||||
}
|
||||
|
||||
// Apply migration 022: Refactor configs to link with tasks
|
||||
if err := a.applyMigration022(); err != nil {
|
||||
log.Printf("Warning: Failed to apply migration 022: %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()")
|
||||
|
||||
@@ -3109,6 +3071,51 @@ func (a *App) applyMigration021() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyMigration022 применяет миграцию 022_refactor_configs_to_tasks.sql
|
||||
func (a *App) applyMigration022() error {
|
||||
log.Printf("Applying migration 022: Refactor configs to link with tasks")
|
||||
|
||||
// Проверяем, существует ли уже поле config_id в tasks
|
||||
var exists bool
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'tasks'
|
||||
AND column_name = 'config_id'
|
||||
)
|
||||
`).Scan(&exists)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check config_id column existence: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
log.Printf("Migration 022 already applied (config_id column exists), skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Читаем SQL из файла миграции
|
||||
migrationPath := "migrations/022_refactor_configs_to_tasks.sql"
|
||||
if _, err := os.Stat(migrationPath); os.IsNotExist(err) {
|
||||
// Пробуем альтернативный путь (в Docker)
|
||||
migrationPath = "/migrations/022_refactor_configs_to_tasks.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 022: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Migration 022 applied successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) initPlayLifeDB() error {
|
||||
// Создаем таблицу projects
|
||||
createProjectsTable := `
|
||||
@@ -6734,6 +6741,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
t.repetition_date,
|
||||
t.progression_base,
|
||||
t.wishlist_id,
|
||||
t.config_id,
|
||||
COALESCE((
|
||||
SELECT COUNT(*)
|
||||
FROM tasks st
|
||||
@@ -6778,6 +6786,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var repetitionDate sql.NullString
|
||||
var progressionBase sql.NullFloat64
|
||||
var wishlistID sql.NullInt64
|
||||
var configID sql.NullInt64
|
||||
var projectNames pq.StringArray
|
||||
var subtaskProjectNames pq.StringArray
|
||||
|
||||
@@ -6791,6 +6800,7 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
&repetitionDate,
|
||||
&progressionBase,
|
||||
&wishlistID,
|
||||
&configID,
|
||||
&task.SubtasksCount,
|
||||
&projectNames,
|
||||
&subtaskProjectNames,
|
||||
@@ -6822,6 +6832,10 @@ func (a *App) getTasksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
wishlistIDInt := int(wishlistID.Int64)
|
||||
task.WishlistID = &wishlistIDInt
|
||||
}
|
||||
if configID.Valid {
|
||||
configIDInt := int(configID.Int64)
|
||||
task.ConfigID = &configIDInt
|
||||
}
|
||||
|
||||
// Объединяем проекты из основной задачи и подзадач
|
||||
allProjects := make(map[string]bool)
|
||||
@@ -6879,6 +6893,7 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var repetitionPeriod sql.NullString
|
||||
var repetitionDate sql.NullString
|
||||
var wishlistID sql.NullInt64
|
||||
var configID sql.NullInt64
|
||||
|
||||
// Сначала получаем значение как строку напрямую, чтобы избежать проблем с NULL
|
||||
var repetitionPeriodStr string
|
||||
@@ -6887,11 +6902,12 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
SELECT id, name, completed, last_completed_at, next_show_at, reward_message, progression_base,
|
||||
CASE WHEN repetition_period IS NULL THEN '' ELSE repetition_period::text END as repetition_period,
|
||||
COALESCE(repetition_date, '') as repetition_date,
|
||||
wishlist_id
|
||||
wishlist_id,
|
||||
config_id
|
||||
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,
|
||||
&task.ID, &task.Name, &task.Completed, &lastCompletedAt, &nextShowAt, &rewardMessage, &progressionBase, &repetitionPeriodStr, &repetitionDateStr, &wishlistID, &configID,
|
||||
)
|
||||
|
||||
log.Printf("Scanned repetition_period for task %d: String='%s', repetition_date='%s'", taskID, repetitionPeriodStr, repetitionDateStr)
|
||||
@@ -6944,6 +6960,10 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
wishlistIDInt := int(wishlistID.Int64)
|
||||
task.WishlistID = &wishlistIDInt
|
||||
}
|
||||
if configID.Valid {
|
||||
configIDInt := int(configID.Int64)
|
||||
task.ConfigID = &configIDInt
|
||||
}
|
||||
|
||||
// Получаем награды основной задачи
|
||||
rewards := make([]Reward, 0)
|
||||
@@ -7044,6 +7064,47 @@ func (a *App) getTaskDetailHandler(w http.ResponseWriter, r *http.Request) {
|
||||
Subtasks: subtasks,
|
||||
}
|
||||
|
||||
// Если задача - тест (есть config_id), загружаем данные конфигурации
|
||||
if configID.Valid {
|
||||
var wordsCount int
|
||||
var maxCards sql.NullInt64
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT words_count, max_cards
|
||||
FROM configs
|
||||
WHERE id = $1
|
||||
`, configID.Int64).Scan(&wordsCount, &maxCards)
|
||||
|
||||
if err == nil {
|
||||
response.WordsCount = &wordsCount
|
||||
if maxCards.Valid {
|
||||
maxCardsInt := int(maxCards.Int64)
|
||||
response.MaxCards = &maxCardsInt
|
||||
}
|
||||
|
||||
// Загружаем связанные словари
|
||||
dictRows, err := a.DB.Query(`
|
||||
SELECT dictionary_id
|
||||
FROM config_dictionaries
|
||||
WHERE config_id = $1
|
||||
`, configID.Int64)
|
||||
if err == nil {
|
||||
defer dictRows.Close()
|
||||
dictionaryIDs := make([]int, 0)
|
||||
for dictRows.Next() {
|
||||
var dictID int
|
||||
if err := dictRows.Scan(&dictID); err == nil {
|
||||
dictionaryIDs = append(dictionaryIDs, dictID)
|
||||
}
|
||||
}
|
||||
if len(dictionaryIDs) > 0 {
|
||||
response.DictionaryIDs = dictionaryIDs
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("Error loading config for task %d: %v", taskID, err)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -7365,6 +7426,66 @@ func (a *App) createTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Если это тест, создаем конфигурацию
|
||||
if req.IsTest {
|
||||
// Валидация: для теста должны быть указаны words_count и хотя бы один словарь
|
||||
if req.WordsCount == nil || *req.WordsCount < 1 {
|
||||
sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.DictionaryIDs) == 0 {
|
||||
sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Создаем конфигурацию теста
|
||||
var configID int
|
||||
if req.MaxCards != nil {
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO configs (user_id, words_count, max_cards)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, userID, *req.WordsCount, *req.MaxCards).Scan(&configID)
|
||||
} else {
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO configs (user_id, words_count)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
`, userID, *req.WordsCount).Scan(&configID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error creating config: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Связываем конфигурацию со словарями
|
||||
for _, dictID := range req.DictionaryIDs {
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO config_dictionaries (config_id, dictionary_id)
|
||||
VALUES ($1, $2)
|
||||
`, configID, dictID)
|
||||
if err != nil {
|
||||
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем задачу, привязывая config_id
|
||||
_, err = tx.Exec(`
|
||||
UPDATE tasks SET config_id = $1 WHERE id = $2
|
||||
`, configID, taskID)
|
||||
if err != nil {
|
||||
log.Printf("Error linking config to task: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Created test config %d for task %d", configID, taskID)
|
||||
}
|
||||
|
||||
// Коммитим транзакцию
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
@@ -7771,6 +7892,116 @@ func (a *App) updateTaskHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем текущий config_id задачи
|
||||
var currentConfigID sql.NullInt64
|
||||
err = tx.QueryRow("SELECT config_id FROM tasks WHERE id = $1", taskID).Scan(¤tConfigID)
|
||||
if err != nil {
|
||||
log.Printf("Error getting current config_id: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error getting task config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Обработка конфигурации теста
|
||||
if req.IsTest {
|
||||
// Валидация: для теста должны быть указаны words_count и хотя бы один словарь
|
||||
if req.WordsCount == nil || *req.WordsCount < 1 {
|
||||
sendErrorWithCORS(w, "Words count is required for test tasks and must be at least 1", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.DictionaryIDs) == 0 {
|
||||
sendErrorWithCORS(w, "At least one dictionary is required for test tasks", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if currentConfigID.Valid {
|
||||
// Обновляем существующую конфигурацию
|
||||
if req.MaxCards != nil {
|
||||
_, err = tx.Exec(`
|
||||
UPDATE configs SET words_count = $1, max_cards = $2 WHERE id = $3
|
||||
`, *req.WordsCount, *req.MaxCards, currentConfigID.Int64)
|
||||
} else {
|
||||
_, err = tx.Exec(`
|
||||
UPDATE configs SET words_count = $1, max_cards = NULL WHERE id = $2
|
||||
`, *req.WordsCount, currentConfigID.Int64)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Error updating config: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error updating config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем связи со словарями
|
||||
_, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64)
|
||||
if err != nil {
|
||||
log.Printf("Error deleting config dictionaries: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error updating config dictionaries: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, dictID := range req.DictionaryIDs {
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2)
|
||||
`, currentConfigID.Int64, dictID)
|
||||
if err != nil {
|
||||
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Создаем новую конфигурацию для существующей задачи
|
||||
var newConfigID int
|
||||
if req.MaxCards != nil {
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO configs (user_id, words_count, max_cards) VALUES ($1, $2, $3) RETURNING id
|
||||
`, userID, *req.WordsCount, *req.MaxCards).Scan(&newConfigID)
|
||||
} else {
|
||||
err = tx.QueryRow(`
|
||||
INSERT INTO configs (user_id, words_count) VALUES ($1, $2) RETURNING id
|
||||
`, userID, *req.WordsCount).Scan(&newConfigID)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Error creating config: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error creating config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
for _, dictID := range req.DictionaryIDs {
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO config_dictionaries (config_id, dictionary_id) VALUES ($1, $2)
|
||||
`, newConfigID, dictID)
|
||||
if err != nil {
|
||||
log.Printf("Error linking dictionary %d to config: %v", dictID, err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error linking dictionary to config: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_, err = tx.Exec("UPDATE tasks SET config_id = $1 WHERE id = $2", newConfigID, taskID)
|
||||
if err != nil {
|
||||
log.Printf("Error linking config to task: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error linking config to task: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if currentConfigID.Valid {
|
||||
// Задача перестала быть тестом - удаляем конфигурацию
|
||||
_, err = tx.Exec("DELETE FROM config_dictionaries WHERE config_id = $1", currentConfigID.Int64)
|
||||
if err != nil {
|
||||
log.Printf("Error deleting config dictionaries: %v", err)
|
||||
}
|
||||
_, err = tx.Exec("DELETE FROM configs WHERE id = $1", currentConfigID.Int64)
|
||||
if err != nil {
|
||||
log.Printf("Error deleting config: %v", err)
|
||||
}
|
||||
_, err = tx.Exec("UPDATE tasks SET config_id = NULL WHERE id = $1", taskID)
|
||||
if err != nil {
|
||||
log.Printf("Error unlinking config from task: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Error unlinking config from task: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Коммитим транзакцию
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("Error committing transaction: %v", err)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Migration: Refactor configs to link via tasks.config_id
|
||||
-- This migration adds config_id to tasks table and migrates existing configs to tasks
|
||||
-- After migration: configs only contain words_count, max_cards (name and try_message removed)
|
||||
|
||||
-- ============================================
|
||||
-- Step 1: Add config_id to tasks
|
||||
-- ============================================
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN IF NOT EXISTS config_id INTEGER REFERENCES configs(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_config_id ON tasks(config_id);
|
||||
|
||||
-- Unique index: only one task per config
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_config_id_unique
|
||||
ON tasks(config_id) WHERE config_id IS NOT NULL AND deleted = FALSE;
|
||||
|
||||
COMMENT ON COLUMN tasks.config_id IS 'Link to test config. NULL if task is not a test.';
|
||||
|
||||
-- ============================================
|
||||
-- Step 2: Migrate existing configs to tasks
|
||||
-- Create a task for each config that doesn't have one yet
|
||||
-- ============================================
|
||||
INSERT INTO tasks (user_id, name, reward_message, repetition_period, repetition_date, config_id)
|
||||
SELECT
|
||||
c.user_id,
|
||||
c.name, -- Config name -> Task name
|
||||
c.try_message, -- try_message -> reward_message
|
||||
'0 day'::INTERVAL, -- repetition_period = 0 (infinite task)
|
||||
'0 week', -- repetition_date = 0 (infinite task)
|
||||
c.id -- Link to config
|
||||
FROM configs c
|
||||
WHERE c.name IS NOT NULL -- Only configs with names
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tasks t WHERE t.config_id = c.id AND t.deleted = FALSE
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- Step 3: Remove name and try_message from configs
|
||||
-- These are now stored in the linked task
|
||||
-- ============================================
|
||||
ALTER TABLE configs DROP COLUMN IF EXISTS name;
|
||||
ALTER TABLE configs DROP COLUMN IF EXISTS try_message;
|
||||
|
||||
-- ============================================
|
||||
-- Comments for documentation
|
||||
-- ============================================
|
||||
COMMENT ON TABLE configs IS 'Test configurations (words_count, max_cards, dictionary associations). Linked to tasks via tasks.config_id.';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user