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

728 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План рефакторинга интеграции с 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`
**Структура после миграции:**
```sql
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`:
```env
# ============================================
# 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`:
```go
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`
**Новая логика:**
```go
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 - ИЗМЕНИТЬ:
```go
// Было:
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`:
```go
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. Перенаправляет пользователя на страницу интеграций
```go
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)
- Возвращает:
```json
{
"connected": true,
"todoist_email": "user@example.com"
}
```
или если не подключено:
```json
{
"connected": false
}
```
- **Примечание:** webhook_url больше не нужен — он единый для всего приложения!
4. **Отключение интеграции:**
- `DELETE /api/integrations/todoist/disconnect` - отключает интеграцию
- Требует авторизацию (protected endpoint)
- **Удаляет запись** из `todoist_integrations` полностью
- Возвращает: `{"success": true, "message": "Todoist disconnected"}`
### 3.5. Новые маршруты:
```go
// 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`
- Содержимое миграции:
```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 токен (не требует хранения в БД):
```go
// Генерация 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 ID** → `TODOIST_CLIENT_ID`
- **Client Secret** → `TODOIST_CLIENT_SECRET`
- **Client secret for webhooks** (если есть) → `TODOIST_WEBHOOK_SECRET`
### Важные настройки:
- **OAuth scope:** `data:read_write`
- **Watched events:** только `item:completed`
- Другие события НЕ подписывать
### Формат webhook от Todoist:
```json
{
"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:
```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