package storage import ( "context" "database/sql" "fmt" ) // CreateTask создаёт задачу со статусом draft. Возвращает её ID. // chat_id и task_tag передаются извне (в диалоге — chat.Address + uuid). func (s *Storage) CreateTask(ctx context.Context, t *Task) (int64, error) { now := Now() res, err := s.db.ExecContext(ctx, ` INSERT INTO tasks (chat_id, title, goal, repo, why, ac, task_tag, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, t.ChatID, t.Title, t.Goal, t.Repo, t.Why, t.AC, t.TaskTag, StatusDraft, now, now, ) if err != nil { return 0, fmt.Errorf("%w: create task: %w", ErrDB, err) } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("%w: last insert id: %w", ErrDB, err) } t.ID = id t.Status = StatusDraft t.CreatedAt = now t.UpdatedAt = now return id, nil } // GetTask возвращает задачу по ID. func (s *Storage) GetTask(ctx context.Context, id int64) (*Task, error) { t := &Task{} err := s.db.QueryRowContext(ctx, ` SELECT id, chat_id, title, goal, repo, why, ac, task_tag, status, created_at, updated_at FROM tasks WHERE id = ?`, id).Scan( &t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &t.Why, &t.AC, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt, ) if err == sql.ErrNoRows { return nil, fmt.Errorf("%w: task %d", ErrNotFound, id) } if err != nil { return nil, fmt.Errorf("%w: get task %d: %w", ErrDB, id, err) } return t, nil } // UpdateTask обновляет поля задачи. Если newStatus указан и отличен от текущего — // проверяет валидность перехода. Возвращает ошибку ErrInvalidStatus при недопустимом переходе. func (s *Storage) UpdateTask(ctx context.Context, t *Task) error { // получаем текущий статус для валидации перехода var oldStatus Status err := s.db.QueryRowContext(ctx, `SELECT status FROM tasks WHERE id = ?`, t.ID).Scan(&oldStatus) if err == sql.ErrNoRows { return fmt.Errorf("%w: task %d", ErrNotFound, t.ID) } if err != nil { return fmt.Errorf("%w: get status %d: %w", ErrDB, t.ID, err) } if t.Status != "" && t.Status != oldStatus { if !IsValidTransition(oldStatus, t.Status) { return fmt.Errorf("%w: %s → %s for task %d", ErrInvalidStatus, oldStatus, t.Status, t.ID) } } else if t.Status == "" { t.Status = oldStatus } now := Now() res, err := s.db.ExecContext(ctx, ` UPDATE tasks SET title=?, goal=?, repo=?, why=?, ac=?, status=?, updated_at=? WHERE id=?`, t.Title, t.Goal, t.Repo, t.Why, t.AC, t.Status, now, t.ID, ) if err != nil { return fmt.Errorf("%w: update task %d: %w", ErrDB, t.ID, err) } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("%w: task %d", ErrNotFound, t.ID) } t.UpdatedAt = now return nil } // GetActiveTaskByChatID возвращает последнюю не-терминальную задачу чата. // Терминальные статусы: success, cancelled, aborted, closed. func (s *Storage) GetActiveTaskByChatID(ctx context.Context, chatID string) (*Task, error) { t := &Task{} err := s.db.QueryRowContext(ctx, ` SELECT id, chat_id, title, goal, repo, why, ac, task_tag, status, created_at, updated_at FROM tasks WHERE chat_id = ? AND status NOT IN ('success','cancelled','aborted','closed') ORDER BY updated_at DESC LIMIT 1`, chatID).Scan( &t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &t.Why, &t.AC, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("%w: no active task for chat %s", ErrNotFound, chatID) } if err != nil { return nil, fmt.Errorf("%w: get active task %s: %w", ErrDB, chatID, err) } return t, nil } // ListTasks возвращает задачи по фильтру. func (s *Storage) ListTasks(ctx context.Context, filter TaskFilter) ([]*Task, error) { if filter.Limit <= 0 { filter.Limit = 50 } where := "1=1" args := []any{} if filter.ChatID != "" { where += " AND chat_id = ?" args = append(args, filter.ChatID) } if filter.Status != "" { where += " AND status = ?" args = append(args, string(filter.Status)) } args = append(args, filter.Limit, filter.Offset) rows, err := s.db.QueryContext(ctx, ` SELECT id, chat_id, title, goal, repo, why, ac, task_tag, status, created_at, updated_at FROM tasks WHERE `+where+` ORDER BY updated_at DESC LIMIT ? OFFSET ?`, args...) if err != nil { return nil, fmt.Errorf("%w: list tasks: %w", ErrDB, err) } defer rows.Close() var tasks []*Task for rows.Next() { t := &Task{} if err := rows.Scan(&t.ID, &t.ChatID, &t.Title, &t.Goal, &t.Repo, &t.Why, &t.AC, &t.TaskTag, &t.Status, &t.CreatedAt, &t.UpdatedAt); err != nil { return nil, fmt.Errorf("%w: scan task: %w", ErrDB, err) } tasks = append(tasks, t) } return tasks, rows.Err() } // DeleteTask удаляет задачу (каскадно — трассы). Только для тестов/админки. func (s *Storage) DeleteTask(ctx context.Context, id int64) error { res, err := s.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id) if err != nil { return fmt.Errorf("%w: delete task %d: %w", ErrDB, id, err) } n, _ := res.RowsAffected() if n == 0 { return fmt.Errorf("%w: task %d", ErrNotFound, id) } return nil }