feat: refactor Todoist integration to single app with OAuth
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 32s
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 32s
- 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
This commit is contained in:
727
TODOIST_REFACTOR_PLAN.md
Normal file
727
TODOIST_REFACTOR_PLAN.md
Normal file
@@ -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:
|
||||||
|
- Используйте: `<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
|
||||||
|
|
||||||
21
env.example
21
env.example
@@ -42,11 +42,24 @@ TELEGRAM_BOT_TOKEN=your-bot-token-here
|
|||||||
WEBHOOK_BASE_URL=https://your-domain.com
|
WEBHOOK_BASE_URL=https://your-domain.com
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Todoist Webhook Configuration (optional)
|
# Todoist Integration Configuration
|
||||||
# ============================================
|
# ============================================
|
||||||
# Секрет для проверки подлинности webhook от Todoist
|
# Единое Todoist приложение для всех пользователей Play Life
|
||||||
# Если задан, все запросы должны содержать заголовок X-Todoist-Webhook-Secret с этим значением
|
# Настроить в: https://developer.todoist.com/appconsole.html
|
||||||
# Оставьте пустым, если не хотите использовать проверку секрета
|
#
|
||||||
|
# В настройках Todoist приложения указать:
|
||||||
|
# - OAuth Redirect URL: <WEBHOOK_BASE_URL>/api/integrations/todoist/oauth/callback
|
||||||
|
# - Webhooks callback URL: <WEBHOOK_BASE_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=
|
TODOIST_WEBHOOK_SECRET=
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"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
|
// Clean up expired refresh tokens
|
||||||
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at < NOW()")
|
a.DB.Exec("DELETE FROM refresh_tokens WHERE expires_at < NOW()")
|
||||||
|
|
||||||
@@ -2662,6 +2669,37 @@ func (a *App) applyMigration012() error {
|
|||||||
return nil
|
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 {
|
func (a *App) initPlayLifeDB() error {
|
||||||
// Создаем таблицу projects
|
// Создаем таблицу projects
|
||||||
createProjectsTable := `
|
createProjectsTable := `
|
||||||
@@ -3488,7 +3526,7 @@ func main() {
|
|||||||
|
|
||||||
// Webhooks - no auth (external services)
|
// Webhooks - no auth (external services)
|
||||||
r.HandleFunc("/webhook/message/post", app.messagePostHandler).Methods("POST", "OPTIONS")
|
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")
|
r.HandleFunc("/webhook/telegram", app.telegramWebhookHandler).Methods("POST", "OPTIONS")
|
||||||
|
|
||||||
// Admin pages (basic access, consider adding auth later)
|
// Admin pages (basic access, consider adding auth later)
|
||||||
@@ -3536,7 +3574,12 @@ func main() {
|
|||||||
// Integrations
|
// Integrations
|
||||||
protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS")
|
protected.HandleFunc("/api/integrations/telegram", app.getTelegramIntegrationHandler).Methods("GET", "OPTIONS")
|
||||||
protected.HandleFunc("/api/integrations/telegram", app.updateTelegramIntegrationHandler).Methods("POST", "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
|
// Admin operations
|
||||||
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
|
protected.HandleFunc("/admin/recreate-mv", app.recreateMaterializedViewHandler).Methods("POST", "OPTIONS")
|
||||||
@@ -3651,11 +3694,13 @@ type TelegramIntegration struct {
|
|||||||
|
|
||||||
// TodoistIntegration представляет запись из таблицы todoist_integrations
|
// TodoistIntegration представляет запись из таблицы todoist_integrations
|
||||||
type TodoistIntegration struct {
|
type TodoistIntegration struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
UserID int `json:"user_id"`
|
UserID int `json:"user_id"`
|
||||||
WebhookToken string `json:"webhook_token"`
|
TodoistUserID *int64 `json:"todoist_user_id,omitempty"`
|
||||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
TodoistEmail *string `json:"todoist_email,omitempty"`
|
||||||
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
AccessToken *string `json:"-"` // Не отдавать в JSON!
|
||||||
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// getTelegramIntegration получает telegram интеграцию из БД
|
// getTelegramIntegration получает telegram интеграцию из БД
|
||||||
@@ -5178,42 +5223,118 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
setCORSHeaders(w)
|
setCORSHeaders(w)
|
||||||
|
|
||||||
// Извлекаем токен из URL
|
// Проверка webhook secret (если настроен)
|
||||||
vars := mux.Vars(r)
|
todoistWebhookSecret := getEnv("TODOIST_WEBHOOK_SECRET", "")
|
||||||
token := vars["token"]
|
if todoistWebhookSecret != "" {
|
||||||
log.Printf("Extracted token from URL: '%s'", token)
|
providedSecret := r.Header.Get("X-Todoist-Hmac-SHA256")
|
||||||
if token == "" {
|
if providedSecret == "" {
|
||||||
log.Printf("Todoist webhook: missing token in URL")
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"error": "Missing webhook token",
|
"error": "Error reading request body",
|
||||||
"message": "Token required in URL",
|
"message": "Failed to read request",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Находим пользователя по токену из todoist_integrations
|
log.Printf("Request body (raw): %s", string(bodyBytes))
|
||||||
var userID int
|
log.Printf("Request body length: %d bytes", len(bodyBytes))
|
||||||
err := a.DB.QueryRow(`
|
|
||||||
SELECT user_id FROM todoist_integrations
|
// Парсим webhook от Todoist
|
||||||
WHERE webhook_token = $1
|
var webhook TodoistWebhook
|
||||||
LIMIT 1
|
if err := json.Unmarshal(bodyBytes, &webhook); err != nil {
|
||||||
`, token).Scan(&userID)
|
log.Printf("Error decoding Todoist webhook: %v", err)
|
||||||
|
log.Printf("Failed to parse body as JSON: %s", string(bodyBytes))
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
log.Printf("Todoist webhook: invalid token: %s", token)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"error": "Invalid webhook token",
|
"error": "Invalid request body",
|
||||||
"message": "Token not found",
|
"message": "Failed to parse JSON",
|
||||||
})
|
})
|
||||||
return
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
@@ -5224,7 +5345,7 @@ func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
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)
|
sendErrorWithCORS(w, "Bot token is now configured via TELEGRAM_BOT_TOKEN environment variable", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getTodoistWebhookURLHandler возвращает URL для Todoist webhook
|
// generateOAuthState генерирует JWT state для OAuth
|
||||||
func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request) {
|
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" {
|
if r.Method == "OPTIONS" {
|
||||||
setCORSHeaders(w)
|
setCORSHeaders(w)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
@@ -5729,46 +6084,62 @@ func (a *App) getTodoistWebhookURLHandler(w http.ResponseWriter, r *http.Request
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем или создаем интеграцию Todoist
|
var todoistEmail sql.NullString
|
||||||
var webhookToken string
|
|
||||||
err := a.DB.QueryRow(`
|
err := a.DB.QueryRow(`
|
||||||
SELECT webhook_token FROM todoist_integrations
|
SELECT todoist_email FROM todoist_integrations
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1 AND access_token IS NOT NULL
|
||||||
`, userID).Scan(&webhookToken)
|
`, userID).Scan(&todoistEmail)
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows || !todoistEmail.Valid {
|
||||||
// Создаем новую интеграцию
|
w.Header().Set("Content-Type", "application/json")
|
||||||
webhookToken, err = generateWebhookToken()
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
if err != nil {
|
"connected": false,
|
||||||
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)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
// Получаем base URL из env
|
sendErrorWithCORS(w, fmt.Sprintf("Failed to get status: %v", err), http.StatusInternalServerError)
|
||||||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
|
||||||
if baseURL == "" {
|
|
||||||
sendErrorWithCORS(w, "WEBHOOK_BASE_URL not configured", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
webhookURL := strings.TrimRight(baseURL, "/") + "/webhook/todoist/" + webhookToken
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"webhook_url": webhookURL,
|
"connected": true,
|
||||||
"webhook_token": webhookToken,
|
"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",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)';
|
||||||
|
|
||||||
@@ -4,45 +4,80 @@ import './Integrations.css'
|
|||||||
|
|
||||||
function TodoistIntegration({ onBack }) {
|
function TodoistIntegration({ onBack }) {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [webhookURL, setWebhookURL] = useState('')
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [todoistEmail, setTodoistEmail] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
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 {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
const response = await authFetch('/api/integrations/todoist/webhook-url')
|
const response = await authFetch('/api/integrations/todoist/status')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}))
|
throw new Error('Ошибка при проверке статуса')
|
||||||
throw new Error(errorData.error || 'Ошибка при загрузке URL webhook')
|
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
if (data.webhook_url) {
|
setConnected(data.connected || false)
|
||||||
setWebhookURL(data.webhook_url)
|
if (data.connected && data.todoist_email) {
|
||||||
} else {
|
setTodoistEmail(data.todoist_email)
|
||||||
throw new Error('Webhook URL не найден в ответе')
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching webhook URL:', error)
|
console.error('Error checking status:', error)
|
||||||
setError(error.message || 'Не удалось загрузить webhook URL')
|
setError(error.message || 'Не удалось проверить статус')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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 {
|
try {
|
||||||
await navigator.clipboard.writeText(webhookURL)
|
setLoading(true)
|
||||||
setCopied(true)
|
setError('')
|
||||||
setTimeout(() => setCopied(false), 2000)
|
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) {
|
} 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 }) {
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold mb-6">TODOist интеграция</h1>
|
<h1 className="text-2xl font-bold mb-6">Todoist интеграция</h1>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
{message && (
|
||||||
<h2 className="text-lg font-semibold mb-4">Webhook URL</h2>
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6 text-green-800">
|
||||||
{loading ? (
|
{message}
|
||||||
<div className="text-gray-500">Загрузка...</div>
|
</div>
|
||||||
) : error ? (
|
)}
|
||||||
<div className="text-red-600 mb-4">
|
|
||||||
<p className="mb-2">{error}</p>
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-500">Загрузка...</div>
|
||||||
|
) : connected ? (
|
||||||
|
<div>
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Статус подключения</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-green-600 font-semibold">✅ Todoist подключен</span>
|
||||||
|
</div>
|
||||||
|
{todoistEmail && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-600">Email: </span>
|
||||||
|
<span className="font-medium">{todoistEmail}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
||||||
|
Как это работает
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-700 mb-2">
|
||||||
|
✅ Todoist подключен! Закрывайте задачи в Todoist — они автоматически
|
||||||
|
появятся в Play Life.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Никаких дополнительных настроек не требуется. Просто закрывайте задачи
|
||||||
|
в Todoist, и они будут обработаны автоматически.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDisconnect}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Отключить Todoist
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Подключение Todoist</h2>
|
||||||
|
<p className="text-gray-700 mb-4">
|
||||||
|
Подключите свой Todoist аккаунт для автоматической обработки закрытых задач.
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={fetchWebhookURL}
|
onClick={handleConnect}
|
||||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
className="px-6 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-semibold"
|
||||||
>
|
>
|
||||||
Попробовать снова
|
Подключить Todoist
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={webhookURL}
|
|
||||||
readOnly
|
|
||||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{copied ? 'Скопировано!' : 'Копировать'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
<h3 className="text-lg font-semibold mb-3 text-blue-900">
|
||||||
Как использовать в приложении TODOist
|
Что нужно сделать
|
||||||
</h3>
|
</h3>
|
||||||
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
||||||
<li>Откройте приложение TODOist на вашем устройстве</li>
|
<li>Нажмите кнопку "Подключить Todoist"</li>
|
||||||
<li>Перейдите в настройки проекта или задачи</li>
|
<li>Авторизуйтесь в Todoist</li>
|
||||||
<li>Найдите раздел "Интеграции" или "Webhooks"</li>
|
<li>Готово! Закрытые задачи будут автоматически обрабатываться</li>
|
||||||
<li>Вставьте скопированный URL webhook в соответствующее поле</li>
|
</ol>
|
||||||
<li>Сохраните настройки</li>
|
</div>
|
||||||
<li>
|
</div>
|
||||||
Теперь при закрытии задач в TODOist они будут автоматически
|
)}
|
||||||
обрабатываться системой
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TodoistIntegration
|
export default TodoistIntegration
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user