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:
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user