Files
play-life/TODOIST_REFACTOR_PLAN.md
Play Life Bot a7128703fe
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 32s
feat: refactor Todoist integration to single app with OAuth
- Single webhook URL for all users

- OAuth authorization flow

- Removed individual webhook tokens

- User identification by todoist_user_id

- Added OAuth endpoints: connect, callback, status, disconnect

- Updated frontend with OAuth flow

- DB migration 013: removed webhook_token, added todoist_user_id, todoist_email, access_token

Version: 2.2.0
2026-01-02 15:34:01 +03:00

32 KiB
Raw Blame History

План рефакторинга интеграции с Todoist

Цель

Переделать интеграцию с Todoist для использования единого приложения, созданного в Todoist Developer Platform. Все пользователи Play Life используют одно Todoist приложение с единым webhook URL.

Текущая реализация

  • Каждый пользователь имеет уникальный webhook_token в таблице todoist_integrations
  • Webhook URL: /webhook/todoist/{token} (токен в URL)
  • Пользователь определяется по токену из URL
  • Пользователь должен вручную копировать webhook URL

Новая реализация (Единое приложение)

  • Единое Todoist приложение для всех пользователей Play Life
  • Единый Webhook URL: /webhook/todoist (без токена!)
  • Webhook настроен в Todoist Developer Console на уровне приложения
  • Пользователь определяется по todoist_user_id из event_data webhook
  • OAuth используется для привязки Todoist аккаунта к Play Life аккаунту
  • Пользователю не нужно ничего настраивать — просто нажать "Подключить Todoist"!

Краткое резюме изменений

База данных:

  • Удалить поле webhook_token (больше не нужно!)
  • Добавить поля: todoist_user_id, todoist_email, access_token

Переменные окружения:

  • TODOIST_CLIENT_ID - Client ID приложения
  • TODOIST_CLIENT_SECRET - Client Secret приложения
  • WEBHOOK_BASE_URL - для формирования OAuth Redirect URI

Backend:

  • Изменить webhook handler — идентификация по todoist_user_id
  • Добавить OAuth endpoints для подключения/отключения
  • Убрать логику с токенами в URL

Frontend:

  • Убрать отображение webhook URL (не нужно!)
  • Показать кнопку "Подключить Todoist"
  • После подключения показать email и статус

1. Изменения в базе данных

Миграция: 013_refactor_todoist_single_app.sql

Изменения в таблице todoist_integrations:

  1. Удалить:

    • webhook_token — больше не нужен! Webhook единый для всего приложения.
  2. Добавить:

    • todoist_user_id (BIGINT) — ID пользователя в Todoist (из OAuth, для идентификации в webhook)
    • todoist_email (VARCHAR(255)) — Email пользователя в Todoist (из OAuth)
    • access_token (TEXT) — OAuth access token (бессрочный в Todoist)
  3. Индексы:

    • Уникальный индекс на todoist_user_id — ключевой для идентификации в webhook!
    • Уникальный индекс на todoist_email
    • Удалить индекс на webhook_token

Структура после миграции:

CREATE TABLE todoist_integrations (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    todoist_user_id BIGINT,  -- ID пользователя в Todoist (КЛЮЧЕВОЕ для webhook!)
    todoist_email VARCHAR(255),  -- Email пользователя в Todoist
    access_token TEXT,  -- OAuth access token (бессрочный)
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT todoist_integrations_user_id_unique UNIQUE (user_id)
);

-- Индексы
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_user_id 
ON todoist_integrations(todoist_user_id) 
WHERE todoist_user_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email 
ON todoist_integrations(todoist_email) 
WHERE todoist_email IS NOT NULL;

Ключевое изменение: todoist_user_id теперь используется для идентификации пользователя при получении webhook от Todoist.


2. Переменные окружения (.env)

Добавить в env.example:

# ============================================
# Todoist OAuth Configuration
# ============================================
# Client ID единого Todoist приложения
# Получить в: https://developer.todoist.com/appconsole.html
TODOIST_CLIENT_ID=your-todoist-client-id

# Client Secret единого Todoist приложения
TODOIST_CLIENT_SECRET=your-todoist-client-secret

# Секрет для проверки подлинности webhook от Todoist (опционально)
# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением
TODOIST_WEBHOOK_SECRET=

Что нужно получить из Todoist приложения:

  1. TODOIST_CLIENT_ID - Client ID приложения
  2. TODOIST_CLIENT_SECRET - Client Secret приложения
  3. TODOIST_WEBHOOK_SECRET (опционально) - для дополнительной безопасности webhook

Важно: В настройках Todoist приложения нужно указать Redirect URI:

  • Используйте: <WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
  • Например, если WEBHOOK_BASE_URL=https://your-domain.com, то Redirect URI: https://your-domain.com/api/integrations/todoist/oauth/callback

3. Изменения в Backend (main.go)

3.1. Обновить структуру TodoistIntegration:

type TodoistIntegration struct {
    ID            int        `json:"id"`
    UserID        int        `json:"user_id"`
    TodoistUserID *int64     `json:"todoist_user_id,omitempty"`  // Ключевое для webhook!
    TodoistEmail  *string    `json:"todoist_email,omitempty"`
    AccessToken   *string    `json:"-"`  // Не отдавать в JSON!
    CreatedAt     *time.Time `json:"created_at,omitempty"`
    UpdatedAt     *time.Time `json:"updated_at,omitempty"`
}

Важно:

  • AccessToken не должен отдаваться в JSON ответах (используйте json:"-")
  • TodoistUserID — ключевое поле для идентификации пользователя в webhook

3.2. Webhook handler (todoistWebhookHandler) - КЛЮЧЕВОЕ ИЗМЕНЕНИЕ:

Новый подход:

  • URL: /webhook/todoist (БЕЗ токена!)
  • Webhook настроен в Todoist Developer Console для всего приложения
  • Извлекает user_id из event_data webhook
  • Находит пользователя по todoist_user_id

Новая логика:

func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
    // CORS, OPTIONS handling
    if r.Method == "OPTIONS" {
        setCORSHeaders(w)
        w.WriteHeader(http.StatusOK)
        return
    }
    setCORSHeaders(w)
    
    // Проверка webhook secret (если настроен)
    todoistWebhookSecret := getEnv("TODOIST_WEBHOOK_SECRET", "")
    if todoistWebhookSecret != "" {
        providedSecret := r.Header.Get("X-Todoist-Hmac-SHA256")
        // TODO: проверить HMAC подпись
    }
    
    // Парсим webhook
    var webhook TodoistWebhook
    if err := json.NewDecoder(r.Body).Decode(&webhook); err != nil {
        log.Printf("Todoist webhook: error decoding: %v", err)
        w.WriteHeader(http.StatusOK)
        return
    }
    
    log.Printf("Todoist webhook: event=%s", webhook.EventName)
    
    // Обрабатываем только item:completed
    if webhook.EventName != "item:completed" {
        log.Printf("Todoist webhook: ignoring event %s", webhook.EventName)
        w.WriteHeader(http.StatusOK)
        return
    }
    
    // Извлекаем user_id из event_data (это Todoist user_id!)
    // Может приходить как string или float64
    var todoistUserID int64
    switch v := webhook.EventData["user_id"].(type) {
    case float64:
        todoistUserID = int64(v)
    case string:
        todoistUserID, _ = strconv.ParseInt(v, 10, 64)
    default:
        log.Printf("Todoist webhook: user_id not found or invalid type in event_data")
        w.WriteHeader(http.StatusOK)
        return
    }
    
    // Находим пользователя Play Life по todoist_user_id
    var userID int
    err := a.DB.QueryRow(`
        SELECT user_id FROM todoist_integrations 
        WHERE todoist_user_id = $1
    `, todoistUserID).Scan(&userID)
    
    if err == sql.ErrNoRows {
        // Пользователь не подключил Play Life — игнорируем
        log.Printf("Todoist webhook: no user found for todoist_user_id=%d (ignoring)", todoistUserID)
        w.WriteHeader(http.StatusOK)
        return
    }
    if err != nil {
        log.Printf("Todoist webhook: DB error: %v", err)
        w.WriteHeader(http.StatusOK)
        return
    }
    
    log.Printf("Todoist webhook: todoist_user_id=%d -> user_id=%d", todoistUserID, userID)
    
    // ... остальная логика обработки события (как раньше) ...
}

3.3. Маршрут webhook - ИЗМЕНИТЬ:

// Было:
r.HandleFunc("/webhook/todoist/{token}", app.todoistWebhookHandler).Methods("POST", "OPTIONS")

// Стало:
r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS")

Важно: Этот URL нужно указать в Todoist Developer Console при настройке приложения!

3.4. Добавить OAuth endpoints:

  1. Инициация OAuth:

    • GET /api/integrations/todoist/oauth/connect - перенаправляет на Todoist OAuth
    • ВАЖНО: Требует авторизацию пользователя (JWT token в cookie или header)
    • Генерирует state параметр с user_id (JWT подписанный jwtSecret)
    • Формирует redirect_uri из WEBHOOK_BASE_URL:
      baseURL := getEnv("WEBHOOK_BASE_URL", "")
      if baseURL == "" {
          sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError)
          return
      }
      redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback"
      
      // Генерируем state с user_id
      state := generateOAuthState(userID, jwtSecret)  // JWT с user_id и exp
      
      // Формируем URL для редиректа
      authURL := fmt.Sprintf(
          "https://todoist.com/oauth/authorize?client_id=%s&scope=data:read_write&state=%s&redirect_uri=%s",
          url.QueryEscape(todoistClientID),
          url.QueryEscape(state),
          url.QueryEscape(redirectURI),
      )
      
      http.Redirect(w, r, authURL, http.StatusTemporaryRedirect)
      
  2. OAuth callback:

    • GET /api/integrations/todoist/oauth/callback - обрабатывает callback от Todoist
    • ПУБЛИЧНЫЙ ENDPOINT (без авторизации, так как пользователь приходит от Todoist)
    • Логика:
      1. Проверяет state параметр (JWT с user_id, exp = 1 день)
      2. Извлекает code из query parameters
      3. Обменивает code на access_token через POST запрос к Todoist
      4. Получает информацию о пользователе через Sync API
      5. Сохраняет/обновляет данные в БД
      6. Перенаправляет пользователя на страницу интеграций
    func (a *App) todoistOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
        frontendURL := getEnv("WEBHOOK_BASE_URL", "")
        redirectSuccess := frontendURL + "/?integration=todoist&status=connected"
        redirectError := frontendURL + "/?integration=todoist&status=error"
    
        // 1. Проверяем state (JWT с user_id, exp = 1 день)
        state := r.URL.Query().Get("state")
        userID, err := validateOAuthState(state, jwtSecret)
        if err != nil {
            log.Printf("Todoist OAuth: invalid state: %v", err)
            http.Redirect(w, r, redirectError+"&message=invalid_state", http.StatusTemporaryRedirect)
            return
        }
    
        // 2. Получаем code
        code := r.URL.Query().Get("code")
        if code == "" {
            log.Printf("Todoist OAuth: no code in callback")
            http.Redirect(w, r, redirectError+"&message=no_code", http.StatusTemporaryRedirect)
            return
        }
    
        // 3. Обмениваем code на access_token
        accessToken, err := exchangeCodeForToken(code, redirectURI)
        if err != nil {
            log.Printf("Todoist OAuth: token exchange failed: %v", err)
            http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect)
            return
        }
    
        // 4. Получаем информацию о пользователе
        todoistUser, err := getTodoistUserInfo(accessToken)
        if err != nil {
            log.Printf("Todoist OAuth: get user info failed: %v", err)
            http.Redirect(w, r, redirectError+"&message=user_info_failed", http.StatusTemporaryRedirect)
            return
        }
    
        log.Printf("Todoist OAuth: user_id=%d connected todoist_user_id=%d email=%s", 
            userID, todoistUser.ID, todoistUser.Email)
    
        // 5. Сохраняем в БД (INSERT или UPDATE)
        _, err = a.DB.Exec(`
            INSERT INTO todoist_integrations (user_id, todoist_user_id, todoist_email, access_token)
            VALUES ($1, $2, $3, $4)
            ON CONFLICT (user_id) DO UPDATE SET
                todoist_user_id = $2,
                todoist_email = $3,
                access_token = $4,
                updated_at = CURRENT_TIMESTAMP
        `, userID, todoistUser.ID, todoistUser.Email, accessToken)
    
        if err != nil {
            log.Printf("Todoist OAuth: DB error: %v", err)
            http.Redirect(w, r, redirectError+"&message=db_error", http.StatusTemporaryRedirect)
            return
        }
    
        // 6. Редирект на страницу интеграций
        http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect)
    }
    
  3. Получение статуса интеграции:

    • GET /api/integrations/todoist/status - возвращает статус подключения
    • Требует авторизацию (protected endpoint)
    • Возвращает:
      {
        "connected": true,
        "todoist_email": "user@example.com"
      }
      
      или если не подключено:
      {
        "connected": false
      }
      
    • Примечание: webhook_url больше не нужен — он единый для всего приложения!
  4. Отключение интеграции:

    • DELETE /api/integrations/todoist/disconnect - отключает интеграцию
    • Требует авторизацию (protected endpoint)
    • Удаляет запись из todoist_integrations полностью
    • Возвращает: {"success": true, "message": "Todoist disconnected"}

3.5. Новые маршруты:

// OAuth endpoints
protected.HandleFunc("/api/integrations/todoist/oauth/connect", app.todoistOAuthConnectHandler).Methods("GET")
r.HandleFunc("/api/integrations/todoist/oauth/callback", app.todoistOAuthCallbackHandler).Methods("GET")  // Публичный!
protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS")
protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS")

// Webhook (единый для всего приложения)
r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS")

// УДАЛИТЬ старый endpoint:
// protected.HandleFunc("/api/integrations/todoist/webhook-url", ...)  // Больше не нужен!

Важно:

  • OAuth callback должен быть публичным (пользователь приходит от Todoist без JWT)
  • Webhook тоже публичный (Todoist отправляет события)
  • /api/integrations/todoist/webhook-urlУДАЛИТЬ, больше не нужен!

4. Изменения в Frontend (TodoistIntegration.jsx)

4.1. Добавить проверку статуса подключения:

  • При загрузке компонента вызывать GET /api/integrations/todoist/status
  • Определять, подключен ли Todoist

4.2. Добавить OAuth flow:

  • Если не подключено:

    • Показать кнопку "Подключить Todoist"
    • При клике: window.location.href = '/api/integrations/todoist/oauth/connect'
    • После OAuth callback backend перенаправит на /?integration=todoist&status=connected
    • При загрузке проверять URL параметры и показывать соответствующее сообщение
  • Если подключено:

    • Показать email пользователя Todoist
    • Показать статус: " Todoist подключен"
    • Кнопка "Отключить Todoist" (вызывает DELETE /api/integrations/todoist/disconnect)
    • Webhook URL не нужен — всё работает автоматически!

4.3. Обновить инструкции:

  • Если не подключено:

    • Инструкция: "Нажмите кнопку 'Подключить Todoist' для авторизации"
  • Если подключено:

    • Инструкция: " Todoist подключен! Закрывайте задачи в Todoist — они автоматически появятся в Play Life."
    • Никаких дополнительных настроек не требуется!

4.4. Удалить:

  • Отображение webhook URL
  • Кнопку "Копировать"
  • Инструкции по настройке webhook в Todoist

5. Порядок выполнения изменений

Шаг 1: Создать миграцию БД

  • Создать файл 013_refactor_todoist_single_app.sql
  • Содержимое миграции:
-- Migration: Refactor todoist_integrations for single Todoist app
-- Webhook теперь единый для всего приложения, токены в URL больше не нужны

-- 1. Добавляем новые поля
ALTER TABLE todoist_integrations 
ADD COLUMN IF NOT EXISTS todoist_user_id BIGINT;

ALTER TABLE todoist_integrations 
ADD COLUMN IF NOT EXISTS todoist_email VARCHAR(255);

ALTER TABLE todoist_integrations 
ADD COLUMN IF NOT EXISTS access_token TEXT;

-- 2. Удаляем webhook_token (больше не нужен!)
ALTER TABLE todoist_integrations 
DROP COLUMN IF EXISTS webhook_token;

-- 3. Удаляем старый индекс на webhook_token
DROP INDEX IF EXISTS idx_todoist_integrations_webhook_token;

-- 4. Создаем новые индексы
CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_user_id 
ON todoist_integrations(todoist_user_id) 
WHERE todoist_user_id IS NOT NULL;

CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email 
ON todoist_integrations(todoist_email) 
WHERE todoist_email IS NOT NULL;

-- 5. Комментарии
COMMENT ON COLUMN todoist_integrations.todoist_user_id IS 'Todoist user ID (from OAuth) - used to identify user in webhooks';
COMMENT ON COLUMN todoist_integrations.todoist_email IS 'Todoist user email (from OAuth)';
COMMENT ON COLUMN todoist_integrations.access_token IS 'Todoist OAuth access token (permanent)';
  • Применить миграцию

Важно: После миграции старые записи с webhook_token будут работать пока не применится миграция. После миграции все пользователи должны переподключить Todoist через OAuth.

Шаг 2: Обновить .env

  • Добавить новые переменные окружения
  • Получить данные из Todoist приложения

Шаг 3: Обновить Backend

  • Обновить структуру TodoistIntegration
  • Изменить webhook handler
  • Добавить OAuth endpoints
  • Обновить маршруты

Шаг 4: Обновить Frontend

  • Обновить компонент TodoistIntegration.jsx
  • Добавить OAuth flow

Шаг 5: Тестирование

  • Протестировать OAuth flow
  • Протестировать webhook с новым способом идентификации
  • Проверить миграцию данных

6. Важные замечания

6.1. Идентификация пользователя в webhook

Новый подход:

  • Используется todoist_user_id из event_data webhook
  • todoist_user_id сохраняется при OAuth подключении
  • Webhook приходит на единый URL /webhook/todoist
  • Находим пользователя Play Life по todoist_user_id

6.2. Миграция существующих данных

  • Удаляем webhook_token — больше не нужен
  • Все существующие записи будут работать после миграции, но без OAuth данных
  • Пользователям нужно переподключить Todoist через OAuth для работы интеграции
  • После миграции старый endpoint /webhook/todoist/{token} перестанет работать

6.3. Обратная совместимость

  • НЕТ обратной совместимости — это breaking change
  • Старый endpoint /webhook/todoist/{token} удаляется
  • Все пользователи должны переподключить Todoist
  • Рекомендация: Уведомить пользователей о необходимости переподключения

6.3.1. Удаляемый код

Удалить полностью:

  • Endpoint GET /api/integrations/todoist/webhook-url
  • Handler getTodoistWebhookURLHandler
  • Маршрут /webhook/todoist/{token}
  • Функция генерации webhook_token для Todoist

6.4. Безопасность

  • OAuth токен (access_token) не отдавать в JSON ответах (json:"-")
  • Использовать TODOIST_WEBHOOK_SECRET для проверки подлинности webhook (если настроен в Todoist)
  • Todoist access_token бессрочный, но пользователь может отозвать его в настройках Todoist
  • User-Agent для запросов к Todoist API: PlayLife

6.5. OAuth Flow (детально)

  1. Пользователь нажимает "Подключить Todoist"
  2. Backend генерирует state (случайная строка или JWT с user_id) и сохраняет его
  3. Перенаправление на Todoist OAuth:
    https://todoist.com/oauth/authorize?
      client_id=<TODOIST_CLIENT_ID>&
      scope=data:read_write&
      state=<state>&
      redirect_uri=<WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
    
  4. Пользователь авторизуется в Todoist
  5. Todoist перенаправляет на redirect_uri с code и state
  6. Backend проверяет state и обменивает code на access_token:
    POST https://todoist.com/oauth/access_token
    Content-Type: application/x-www-form-urlencoded
    
    client_id=<TODOIST_CLIENT_ID>&
    client_secret=<TODOIST_CLIENT_SECRET>&
    code=<code>&
    redirect_uri=<redirect_uri>
    
  7. Backend получает информацию о пользователе через Todoist Sync API:
    POST https://api.todoist.com/sync/v9/sync
    Authorization: Bearer <access_token>
    Content-Type: application/x-www-form-urlencoded
    User-Agent: PlayLife
    
    sync_token=*&resource_types=["user"]
    
    Ответ содержит user.id и user.email
  8. Backend сохраняет todoist_user_id, todoist_email, access_token в БД
  9. Перенаправление пользователя на страницу интеграций

6.6. Хранение state для OAuth

Используем JWT токен (не требует хранения в БД):

// Генерация state (таймаут = 1 день)
func generateOAuthState(userID int, jwtSecret string) string {
    state := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "user_id": userID,
        "type":    "todoist_oauth",
        "exp":     time.Now().Add(24 * time.Hour).Unix(),  // 1 день
    })
    stateString, _ := state.SignedString([]byte(jwtSecret))
    return stateString
}

// Проверка state в callback
func validateOAuthState(stateString string, jwtSecret string) (int, error) {
    token, err := jwt.Parse(stateString, func(token *jwt.Token) (interface{}, error) {
        return []byte(jwtSecret), nil
    })
    if err != nil {
        return 0, err
    }
    
    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok || !token.Valid {
        return 0, fmt.Errorf("invalid token")
    }
    
    if claims["type"] != "todoist_oauth" {
        return 0, fmt.Errorf("wrong token type")
    }
    
    userID := int(claims["user_id"].(float64))
    return userID, nil
}

6.7. Особенности Todoist OAuth

  • Scope: data:read_write — полный доступ к данным пользователя
  • Access Token: Todoist выдает бессрочный access_token
  • Refresh Token: Todoist НЕ использует refresh_token
  • Отзыв токена: Пользователь может отозвать доступ в настройках Todoist

6.8. Обработка ошибок

Если todoist_user_id не найден в webhook:

  • Логировать: log.Printf("Todoist webhook: no user found for todoist_user_id=%d", todoistUserID)
  • Возвращать 200 OK (чтобы Todoist не делал retry)
  • Игнорировать событие

Если токен отозван пользователем:

  • При попытке использовать access_token Todoist вернет ошибку
  • Автоматически отключить интеграцию (удалить запись из БД)
  • Логировать: log.Printf("Todoist: token revoked for user_id=%d, disconnecting", userID)

При disconnect:

  • Просто удалить запись из БД
  • НЕ отзывать токен через Todoist API (упрощение)

6.9. События Todoist

Подписываемся только на: item:completed

Другие события (item:added, item:updated, item:deleted) не нужны.


7. Архитектура: Единый Webhook

Ключевое решение: Используем единый webhook URL для всего приложения.

Как это работает:

  1. Настройка в Todoist Developer Console:

    • Создать приложение в https://developer.todoist.com/appconsole.html
    • Указать Webhook URL: <WEBHOOK_BASE_URL>/webhook/todoist
    • Указать OAuth Redirect URI: <WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
    • Выбрать события: item:completed
  2. При OAuth подключении:

    • Пользователь нажимает "Подключить Todoist"
    • Авторизуется в Todoist
    • Play Life получает access_token и информацию о пользователе
    • Сохраняем todoist_user_id — это ключ для идентификации в webhook
  3. При получении webhook:

    • Todoist отправляет POST на /webhook/todoist
    • В event_data есть user_id (это Todoist user_id)
    • Находим пользователя Play Life по todoist_user_id
    • Обрабатываем событие

Преимущества:

  • Пользователю не нужно ничего настраивать!
  • Нет токенов в URL
  • Простая архитектура
  • Webhook настраивается один раз в Developer Console

8. Настройка Todoist приложения в Developer Console

Шаги настройки:

  1. Зайти в https://developer.todoist.com/appconsole.html
  2. Создать новое приложение или открыть существующее
  3. Заполнить:
    • App name: Play Life
    • App description: Интеграция с Play Life для отслеживания прогресса
    • OAuth Redirect URL: <WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
    • Webhooks callback URL: <WEBHOOK_BASE_URL>/webhook/todoist
    • Watched events: item:completed (только это событие!)
  4. Скопировать:
    • Client IDTODOIST_CLIENT_ID
    • Client SecretTODOIST_CLIENT_SECRET
    • Client secret for webhooks (если есть) → TODOIST_WEBHOOK_SECRET

Важные настройки:

  • OAuth scope: data:read_write
  • Watched events: только item:completed
  • Другие события НЕ подписывать

Формат webhook от Todoist:

{
  "event_name": "item:completed",
  "user_id": "12345678",  // ← Это todoist_user_id для идентификации!
  "event_data": {
    "id": "task_id",
    "content": "Task title",
    "description": "Task description",
    "user_id": "12345678",  // ← Тоже здесь
    ...
  }
}

Важно: user_id приходит как string, нужно конвертировать в int64.


9. Краткая сводка для быстрого старта

Настройка Todoist приложения:

  1. Зайти в https://developer.todoist.com/appconsole.html
  2. Создать приложение
  3. Настроить:
    • OAuth Redirect URL: <WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
    • Webhooks callback URL: <WEBHOOK_BASE_URL>/webhook/todoist
    • Watched events: item:completed
  4. Скопировать Client ID и Client Secret

Что добавить в .env:

TODOIST_CLIENT_ID=your-client-id-here
TODOIST_CLIENT_SECRET=your-client-secret-here
TODOIST_WEBHOOK_SECRET=  # опционально, из Developer Console

Что изменится в базе данных:

  • Добавятся поля: todoist_user_id, todoist_email, access_token
  • Удалится поле: webhook_token

Что изменится для пользователей:

  • Пользователи нажимают "Подключить Todoist"
  • Авторизуются в Todoist
  • Готово! Никаких дополнительных настроек!
  • Закрытые задачи в Todoist автоматически появляются в Play Life

Порядок реализации:

  1. Настроить Todoist приложение в Developer Console
  2. Создать миграцию БД (013_refactor_todoist_single_app.sql)
  3. Обновить .env с новыми переменными
  4. Реализовать OAuth endpoints в Backend
  5. Обновить webhook handler (идентификация по todoist_user_id)
  6. Обновить Frontend компонент
  7. Удалить старый код (webhook-url endpoint, токены)
  8. Протестировать OAuth flow и webhook