diff --git a/TODOIST_REFACTOR_PLAN.md b/TODOIST_REFACTOR_PLAN.md new file mode 100644 index 0000000..931b2f7 --- /dev/null +++ b/TODOIST_REFACTOR_PLAN.md @@ -0,0 +1,727 @@ +# План рефакторинга интеграции с 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: +- Используйте: `/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=& + scope=data:read_write& + state=& + redirect_uri=/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=& + client_secret=& + code=& + redirect_uri= + ``` +7. Backend получает информацию о пользователе через Todoist Sync API: + ``` + POST https://api.todoist.com/sync/v9/sync + Authorization: Bearer + 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/todoist` + - Указать OAuth Redirect URI: `/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:** `/api/integrations/todoist/oauth/callback` + - **Webhooks callback 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:** `/api/integrations/todoist/oauth/callback` + - **Webhooks callback 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 + diff --git a/VERSION b/VERSION index 7ec1d6d..ccbccc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.0 +2.2.0 diff --git a/env.example b/env.example index a187e01..2951ab9 100644 --- a/env.example +++ b/env.example @@ -42,11 +42,24 @@ TELEGRAM_BOT_TOKEN=your-bot-token-here WEBHOOK_BASE_URL=https://your-domain.com # ============================================ -# Todoist Webhook Configuration (optional) +# Todoist Integration Configuration # ============================================ -# Секрет для проверки подлинности webhook от Todoist -# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением -# Оставьте пустым, если не хотите использовать проверку секрета +# Единое Todoist приложение для всех пользователей Play Life +# Настроить в: https://developer.todoist.com/appconsole.html +# +# В настройках Todoist приложения указать: +# - OAuth Redirect URL: /api/integrations/todoist/oauth/callback +# - Webhooks callback URL: /webhook/todoist +# - Watched events: item:completed + +# Client ID единого Todoist приложения +TODOIST_CLIENT_ID= + +# Client Secret единого Todoist приложения +TODOIST_CLIENT_SECRET= + +# Секрет для проверки подлинности webhook от Todoist (опционально) +# Получить в Developer Console: "Client secret for webhooks" TODOIST_WEBHOOK_SECRET= # ============================================ diff --git a/play-life-backend/main.go b/play-life-backend/main.go index fcf8a02..387484f 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -12,6 +12,7 @@ import ( "log" "math" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -2561,6 +2562,12 @@ func (a *App) initAuthDB() error { // Не возвращаем ошибку, чтобы приложение могло запуститься } + // Apply migration 013: Refactor todoist_integrations for single Todoist app + if err := a.applyMigration013(); err != nil { + log.Printf("Warning: Failed to apply migration 013: %v", err) + // Не возвращаем ошибку, чтобы приложение могло запуститься + } + // Clean up expired refresh tokens a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at < NOW()") @@ -2662,6 +2669,37 @@ func (a *App) applyMigration012() error { return nil } +// applyMigration013 применяет миграцию 013_refactor_todoist_single_app.sql +func (a *App) applyMigration013() error { + log.Printf("Applying migration 013: Refactor todoist_integrations for single Todoist app") + + // 1. Добавляем новые поля + a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS todoist_user_id BIGINT") + a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS todoist_email VARCHAR(255)") + a.DB.Exec("ALTER TABLE todoist_integrations ADD COLUMN IF NOT EXISTS access_token TEXT") + + // 2. Удаляем webhook_token + a.DB.Exec("ALTER TABLE todoist_integrations DROP COLUMN IF EXISTS webhook_token") + + // 3. Удаляем старый индекс + a.DB.Exec("DROP INDEX IF EXISTS idx_todoist_integrations_webhook_token") + + // 4. Создаем новые индексы + a.DB.Exec(` + 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 + `) + a.DB.Exec(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_todoist_integrations_todoist_email + ON todoist_integrations(todoist_email) + WHERE todoist_email IS NOT NULL + `) + + log.Printf("Migration 013 applied successfully") + return nil +} + func (a *App) initPlayLifeDB() error { // Создаем таблицу projects createProjectsTable := ` @@ -3488,7 +3526,7 @@ func main() { // Webhooks - no auth (external services) r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS") - r.HandleFunc("/webhook/todoist/{token}", app.todoistWebhookHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/webhook/todoist", app.todoistWebhookHandler).Methods("POST", "OPTIONS") r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS") // Admin pages (basic access, consider adding auth later) @@ -3536,7 +3574,12 @@ func main() { // Integrations protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS") protected.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "OPTIONS") - protected.HandleFunc("/api/integrations/todoist/webhook-url", app.getTodoistWebhookURLHandler).Methods("GET", "OPTIONS") + + // Todoist 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") // Admin operations protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS") @@ -3651,11 +3694,13 @@ type TelegramIntegration struct { // TodoistIntegration представляет запись из таблицы todoist_integrations type TodoistIntegration struct { - ID int `json:"id"` - UserID int `json:"user_id"` - WebhookToken string `json:"webhook_token"` - CreatedAt *time.Time `json:"created_at,omitempty"` - UpdatedAt *time.Time `json:"updated_at,omitempty"` + ID int `json:"id"` + UserID int `json:"user_id"` + TodoistUserID *int64 `json:"todoist_user_id,omitempty"` + TodoistEmail *string `json:"todoist_email,omitempty"` + AccessToken *string `json:"-"` // Не отдавать в JSON! + CreatedAt *time.Time `json:"created_at,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` } // getTelegramIntegration получает telegram интеграцию из БД @@ -5178,42 +5223,118 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { } setCORSHeaders(w) - // Извлекаем токен из URL - vars := mux.Vars(r) - token := vars["token"] - log.Printf("Extracted token from URL: '%s'", token) - if token == "" { - log.Printf("Todoist webhook: missing token in URL") + // Проверка webhook secret (если настроен) + todoistWebhookSecret := getEnv("TODOIST_WEBHOOK_SECRET", "") + if todoistWebhookSecret != "" { + providedSecret := r.Header.Get("X-Todoist-Hmac-SHA256") + if providedSecret == "" { + providedSecret = r.Header.Get("X-Todoist-Webhook-Secret") + } + if providedSecret != todoistWebhookSecret { + log.Printf("Invalid Todoist webhook secret provided") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": false, + "error": "Unauthorized", + "message": "Invalid webhook secret", + }) + return + } + log.Printf("Webhook secret validated successfully") + } + + // Читаем тело запроса + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("Error reading request body: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, - "error": "Missing webhook token", - "message": "Token required in URL", + "error": "Error reading request body", + "message": "Failed to read request", }) return } - // Находим пользователя по токену из todoist_integrations - var userID int - err := a.DB.QueryRow(` - SELECT user_id FROM todoist_integrations - WHERE webhook_token = $1 - LIMIT 1 - `, token).Scan(&userID) - - if err == sql.ErrNoRows { - log.Printf("Todoist webhook: invalid token: %s", token) + log.Printf("Request body (raw): %s", string(bodyBytes)) + log.Printf("Request body length: %d bytes", len(bodyBytes)) + + // Парсим webhook от Todoist + var webhook TodoistWebhook + if err := json.Unmarshal(bodyBytes, &webhook); err != nil { + log.Printf("Error decoding Todoist webhook: %v", err) + log.Printf("Failed to parse body as JSON: %s", string(bodyBytes)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ "ok": false, - "error": "Invalid webhook token", - "message": "Token not found", + "error": "Invalid request body", + "message": "Failed to parse JSON", }) return - } else if err != nil { - log.Printf("Error finding user by webhook token: %v", err) + } + + // Логируем структуру webhook + log.Printf("Parsed webhook structure:") + log.Printf(" EventName: %s", webhook.EventName) + log.Printf(" EventData keys: %v", getMapKeys(webhook.EventData)) + + // Проверяем, что это событие закрытия задачи + if webhook.EventName != "item:completed" { + log.Printf("Received Todoist event '%s', ignoring (only processing 'item:completed')", webhook.EventName) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "message": "Event ignored", + "event": webhook.EventName, + }) + return + } + + // Извлекаем user_id из event_data (это Todoist user_id!) + 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.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": false, + "error": "Missing user_id in event_data", + "message": "Cannot identify user", + }) + return + } + + log.Printf("Todoist webhook: todoist_user_id=%d", todoistUserID) + + // Находим пользователя 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.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "message": "User not found (not connected)", + }) + return + } + if err != nil { + log.Printf("Error finding user by todoist_user_id: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]interface{}{ @@ -5224,7 +5345,7 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { return } - log.Printf("Todoist webhook: token=%s, user_id=%d", token, userID) + log.Printf("Todoist webhook: todoist_user_id=%d -> user_id=%d", todoistUserID, userID) // Читаем тело запроса для логирования bodyBytes, err := io.ReadAll(r.Body) @@ -5714,8 +5835,242 @@ func (a *App) updateTelegramIntegrationHandler(w http.ResponseWriter, r *http.Re sendErrorWithCORS(w, "Bot token is now configured via TELEGRAM_BOT_TOKEN environment variable", http.StatusBadRequest) } -// getTodoistWebhookURLHandler возвращает URL для Todoist webhook -func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request) { +// generateOAuthState генерирует JWT state для OAuth +func generateOAuthState(userID int, jwtSecret string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": userID, + "type": "todoist_oauth", + "exp": time.Now().Add(24 * time.Hour).Unix(), // 1 день + }) + return token.SignedString([]byte(jwtSecret)) +} + +// validateOAuthState проверяет и извлекает user_id из JWT state +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 +} + +// exchangeCodeForToken обменивает OAuth code на access_token +func exchangeCodeForToken(code, redirectURI, clientID, clientSecret string) (string, error) { + data := url.Values{} + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + + resp, err := http.PostForm("https://todoist.com/oauth/access_token", data) + if err != nil { + return "", fmt.Errorf("failed to exchange code: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("token exchange failed: %s", string(body)) + } + + var result struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + if result.Error != "" { + return "", fmt.Errorf("token exchange error: %s", result.Error) + } + + return result.AccessToken, nil +} + +// getTodoistUserInfo получает информацию о пользователе через Sync API +func getTodoistUserInfo(accessToken string) (struct { + ID int64 + Email string +}, error) { + var userInfo struct { + ID int64 + Email string + } + + req, err := http.NewRequest("POST", "https://api.todoist.com/sync/v9/sync", strings.NewReader("sync_token=*&resource_types=[\"user\"]")) + if err != nil { + return userInfo, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "PlayLife") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return userInfo, fmt.Errorf("failed to get user info: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return userInfo, fmt.Errorf("get user info failed: %s", string(body)) + } + + var result struct { + User struct { + ID int64 `json:"id"` + Email string `json:"email"` + } `json:"user"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return userInfo, fmt.Errorf("failed to decode user info: %w", err) + } + + userInfo.ID = result.User.ID + userInfo.Email = result.User.Email + return userInfo, nil +} + +// todoistOAuthConnectHandler инициирует OAuth flow +func (a *App) todoistOAuthConnectHandler(w http.ResponseWriter, r *http.Request) { + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + clientID := getEnv("TODOIST_CLIENT_ID", "") + clientSecret := getEnv("TODOIST_CLIENT_SECRET", "") + jwtSecret := getEnv("JWT_SECRET", "") + baseURL := getEnv("WEBHOOK_BASE_URL", "") + + if clientID == "" || clientSecret == "" { + sendErrorWithCORS(w, "TODOIST_CLIENT_ID and TODOIST_CLIENT_SECRET must be configured", http.StatusInternalServerError) + return + } + if baseURL == "" { + sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError) + return + } + if jwtSecret == "" { + sendErrorWithCORS(w, "JWT_SECRET must be configured", http.StatusInternalServerError) + return + } + + redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback" + + state, err := generateOAuthState(userID, jwtSecret) + if err != nil { + log.Printf("Todoist OAuth: failed to generate state: %v", err) + sendErrorWithCORS(w, "Failed to generate OAuth state", http.StatusInternalServerError) + return + } + + authURL := fmt.Sprintf( + "https://todoist.com/oauth/authorize?client_id=%s&scope=data:read_write&state=%s&redirect_uri=%s", + url.QueryEscape(clientID), + url.QueryEscape(state), + url.QueryEscape(redirectURI), + ) + + log.Printf("Todoist OAuth: redirecting user_id=%d to Todoist", userID) + http.Redirect(w, r, authURL, http.StatusTemporaryRedirect) +} + +// todoistOAuthCallbackHandler обрабатывает OAuth callback +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" + + jwtSecret := getEnv("JWT_SECRET", "") + clientID := getEnv("TODOIST_CLIENT_ID", "") + clientSecret := getEnv("TODOIST_CLIENT_SECRET", "") + baseURL := getEnv("WEBHOOK_BASE_URL", "") + + if jwtSecret == "" || clientID == "" || clientSecret == "" || baseURL == "" { + log.Printf("Todoist OAuth: missing configuration") + http.Redirect(w, r, redirectError+"&message=config_error", http.StatusTemporaryRedirect) + return + } + + redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/todoist/oauth/callback" + + // Проверяем state + 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 + } + + // Получаем 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 + } + + // Обмениваем code на access_token + accessToken, err := exchangeCodeForToken(code, redirectURI, clientID, clientSecret) + if err != nil { + log.Printf("Todoist OAuth: token exchange failed: %v", err) + http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect) + return + } + + // Получаем информацию о пользователе + 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) + + // Сохраняем в БД + _, 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 + } + + // Редирект на страницу интеграций + http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect) +} + +// getTodoistStatusHandler возвращает статус подключения Todoist +func (a *App) getTodoistStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method == "OPTIONS" { setCORSHeaders(w) w.WriteHeader(http.StatusOK) @@ -5729,46 +6084,62 @@ func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request return } - // Получаем или создаем интеграцию Todoist - var webhookToken string + var todoistEmail sql.NullString err := a.DB.QueryRow(` - SELECT webhook_token FROM todoist_integrations - WHERE user_id = $1 - `, userID).Scan(&webhookToken) - - if err == sql.ErrNoRows { - // Создаем новую интеграцию - webhookToken, err = generateWebhookToken() - if err != nil { - sendErrorWithCORS(w, fmt.Sprintf("Failed to generate token: %v", err), http.StatusInternalServerError) - return - } - _, err = a.DB.Exec(` - INSERT INTO todoist_integrations (user_id, webhook_token) - VALUES ($1, $2) - `, userID, webhookToken) - if err != nil { - sendErrorWithCORS(w, fmt.Sprintf("Failed to create integration: %v", err), http.StatusInternalServerError) - return - } - } else if err != nil { - sendErrorWithCORS(w, fmt.Sprintf("Failed to get integration: %v", err), http.StatusInternalServerError) + SELECT todoist_email FROM todoist_integrations + WHERE user_id = $1 AND access_token IS NOT NULL + `, userID).Scan(&todoistEmail) + + if err == sql.ErrNoRows || !todoistEmail.Valid { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "connected": false, + }) return } - - // Получаем base URL из env - baseURL := getEnv("WEBHOOK_BASE_URL", "") - if baseURL == "" { - sendErrorWithCORS(w, "WEBHOOK_BASE_URL not configured", http.StatusInternalServerError) + if err != nil { + sendErrorWithCORS(w, fmt.Sprintf("Failed to get status: %v", err), http.StatusInternalServerError) return } - webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist/" + webhookToken - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{ - "webhook_url": webhookURL, - "webhook_token": webhookToken, + json.NewEncoder(w).Encode(map[string]interface{}{ + "connected": true, + "todoist_email": todoistEmail.String, + }) +} + +// todoistDisconnectHandler отключает интеграцию Todoist +func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + userID, ok := getUserIDFromContext(r) + if !ok { + sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized) + return + } + + _, err := a.DB.Exec(` + DELETE FROM todoist_integrations WHERE user_id = $1 + `, userID) + + if err != nil { + log.Printf("Todoist disconnect: DB error: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Failed to disconnect: %v", err), http.StatusInternalServerError) + return + } + + log.Printf("Todoist disconnected for user_id=%d", userID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Todoist disconnected", }) } diff --git a/play-life-backend/migrations/013_refactor_todoist_single_app.sql b/play-life-backend/migrations/013_refactor_todoist_single_app.sql new file mode 100644 index 0000000..6103250 --- /dev/null +++ b/play-life-backend/migrations/013_refactor_todoist_single_app.sql @@ -0,0 +1,45 @@ +-- Migration: Refactor todoist_integrations for single Todoist app +-- Webhook теперь единый для всего приложения, токены в URL больше не нужны +-- Все пользователи используют одно Todoist приложение + +-- ============================================ +-- 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)'; + diff --git a/play-life-web/src/components/TodoistIntegration.jsx b/play-life-web/src/components/TodoistIntegration.jsx index 44644b2..291f139 100644 --- a/play-life-web/src/components/TodoistIntegration.jsx +++ b/play-life-web/src/components/TodoistIntegration.jsx @@ -4,45 +4,80 @@ import './Integrations.css' function TodoistIntegration({ onBack }) { const { authFetch } = useAuth() - const [webhookURL, setWebhookURL] = useState('') + const [connected, setConnected] = useState(false) + const [todoistEmail, setTodoistEmail] = useState('') const [loading, setLoading] = useState(true) - const [copied, setCopied] = useState(false) const [error, setError] = useState('') + const [message, setMessage] = useState('') useEffect(() => { - fetchWebhookURL() + checkStatus() + // Проверяем URL параметры для сообщений + const params = new URLSearchParams(window.location.search) + const integration = params.get('integration') + const status = params.get('status') + if (integration === 'todoist') { + if (status === 'connected') { + setMessage('✅ Todoist успешно подключен!') + // Очищаем URL параметры + window.history.replaceState({}, '', window.location.pathname) + } else if (status === 'error') { + const errorMsg = params.get('message') || 'Произошла ошибка' + setError(errorMsg) + window.history.replaceState({}, '', window.location.pathname) + } + } }, []) - const fetchWebhookURL = async () => { + const checkStatus = async () => { try { setLoading(true) setError('') - const response = await authFetch('/api/integrations/todoist/webhook-url') + const response = await authFetch('/api/integrations/todoist/status') if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Ошибка при загрузке URL webhook') + throw new Error('Ошибка при проверке статуса') } const data = await response.json() - if (data.webhook_url) { - setWebhookURL(data.webhook_url) - } else { - throw new Error('Webhook URL не найден в ответе') + setConnected(data.connected || false) + if (data.connected && data.todoist_email) { + setTodoistEmail(data.todoist_email) } } catch (error) { - console.error('Error fetching webhook URL:', error) - setError(error.message || 'Не удалось загрузить webhook URL') + console.error('Error checking status:', error) + setError(error.message || 'Не удалось проверить статус') } finally { setLoading(false) } } - const copyToClipboard = async () => { + const handleConnect = () => { + // Перенаправляем на OAuth endpoint + window.location.href = '/api/integrations/todoist/oauth/connect' + } + + const handleDisconnect = async () => { + if (!window.confirm('Вы уверены, что хотите отключить Todoist?')) { + return + } + try { - await navigator.clipboard.writeText(webhookURL) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + setLoading(true) + setError('') + const response = await authFetch('/api/integrations/todoist/disconnect', { + method: 'DELETE', + }) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || 'Ошибка при отключении') + } + setConnected(false) + setTodoistEmail('') + setMessage('Todoist отключен') } catch (error) { - console.error('Error copying to clipboard:', error) + console.error('Error disconnecting:', error) + setError(error.message || 'Не удалось отключить Todoist') + } finally { + setLoading(false) } } @@ -52,59 +87,89 @@ function TodoistIntegration({ onBack }) { ✕ -

TODOist интеграция

+

Todoist интеграция

-
-

Webhook URL

- {loading ? ( -
Загрузка...
- ) : error ? ( -
-

{error}

+ {message && ( +
+ {message} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Загрузка...
+ ) : connected ? ( +
+
+

Статус подключения

+
+
+ ✅ Todoist подключен +
+ {todoistEmail && ( +
+ Email: + {todoistEmail} +
+ )} +
+
+ +
+

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

+

+ ✅ Todoist подключен! Закрывайте задачи в Todoist — они автоматически + появятся в Play Life. +

+

+ Никаких дополнительных настроек не требуется. Просто закрывайте задачи + в Todoist, и они будут обработаны автоматически. +

+
+ + +
+ ) : ( +
+
+

Подключение Todoist

+

+ Подключите свой Todoist аккаунт для автоматической обработки закрытых задач. +

- ) : ( -
- - -
- )} -
-
-

- Как использовать в приложении TODOist -

-
    -
  1. Откройте приложение TODOist на вашем устройстве
  2. -
  3. Перейдите в настройки проекта или задачи
  4. -
  5. Найдите раздел "Интеграции" или "Webhooks"
  6. -
  7. Вставьте скопированный URL webhook в соответствующее поле
  8. -
  9. Сохраните настройки
  10. -
  11. - Теперь при закрытии задач в TODOist они будут автоматически - обрабатываться системой -
  12. -
-
+
+

+ Что нужно сделать +

+
    +
  1. Нажмите кнопку "Подключить Todoist"
  2. +
  3. Авторизуйтесь в Todoist
  4. +
  5. Готово! Закрытые задачи будут автоматически обрабатываться
  6. +
+
+
+ )}
) } export default TodoistIntegration -