Добавлена интеграция с Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s
This commit is contained in:
@@ -3816,6 +3816,72 @@ func (a *App) startDailyReportScheduler() {
|
||||
// Планировщик будет работать в фоновом режиме
|
||||
}
|
||||
|
||||
// startFitbitSyncScheduler запускает планировщик для синхронизации данных Fitbit каждые 4 часа
|
||||
func (a *App) startFitbitSyncScheduler() {
|
||||
// Создаем планировщик в UTC (синхронизация не зависит от часового пояса пользователя)
|
||||
c := cron.New(cron.WithLocation(time.UTC))
|
||||
|
||||
// Добавляем задачу: каждые 4 часа
|
||||
// Cron выражение: "0 */4 * * *" означает: минута=0, каждый 4-й час, любой день месяца, любой месяц, любой день недели
|
||||
_, err := c.AddFunc("0 */4 * * *", func() {
|
||||
log.Printf("Scheduled task: Syncing Fitbit data for all users")
|
||||
if err := a.syncFitbitDataForAllUsers(); err != nil {
|
||||
log.Printf("Error in scheduled Fitbit sync: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error adding cron job for Fitbit sync: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Запускаем планировщик
|
||||
c.Start()
|
||||
log.Printf("Fitbit sync scheduler started: every 4 hours")
|
||||
|
||||
// Планировщик будет работать в фоновом режиме
|
||||
}
|
||||
|
||||
// syncFitbitDataForAllUsers синхронизирует данные Fitbit для всех подключенных пользователей
|
||||
func (a *App) syncFitbitDataForAllUsers() error {
|
||||
rows, err := a.DB.Query(`
|
||||
SELECT user_id FROM fitbit_integrations
|
||||
WHERE access_token IS NOT NULL
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var userIDs []int
|
||||
for rows.Next() {
|
||||
var userID int
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
log.Printf("Error scanning user_id: %v", err)
|
||||
continue
|
||||
}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("error iterating users: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Syncing Fitbit data for %d users", len(userIDs))
|
||||
|
||||
// Синхронизируем данные за сегодня для каждого пользователя
|
||||
today := time.Now()
|
||||
for _, userID := range userIDs {
|
||||
if err := a.syncFitbitData(userID, today); err != nil {
|
||||
log.Printf("Failed to sync Fitbit data for user_id=%d: %v", userID, err)
|
||||
// Продолжаем синхронизацию для остальных пользователей
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startEndOfDayTaskScheduler запускает планировщик для автовыполнения задач в конце дня
|
||||
// каждый день в 23:55 в указанном часовом поясе
|
||||
func (a *App) startEndOfDayTaskScheduler() {
|
||||
@@ -4078,6 +4144,9 @@ func main() {
|
||||
// Запускаем планировщик для автовыполнения задач в конце дня в 23:55
|
||||
app.startEndOfDayTaskScheduler()
|
||||
|
||||
// Запускаем планировщик синхронизации Fitbit каждые 4 часа
|
||||
app.startFitbitSyncScheduler()
|
||||
|
||||
r := mux.NewRouter()
|
||||
|
||||
// Public auth routes (no authentication required)
|
||||
@@ -4172,6 +4241,15 @@ func main() {
|
||||
protected.HandleFunc("/api/integrations/todoist/status", app.getTodoistStatusHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/integrations/todoist/disconnect", app.todoistDisconnectHandler).Methods("DELETE", "OPTIONS")
|
||||
|
||||
// Fitbit OAuth endpoints
|
||||
protected.HandleFunc("/api/integrations/fitbit/oauth/connect", app.fitbitOAuthConnectHandler).Methods("GET")
|
||||
r.HandleFunc("/api/integrations/fitbit/oauth/callback", app.fitbitOAuthCallbackHandler).Methods("GET") // Публичный!
|
||||
protected.HandleFunc("/api/integrations/fitbit/status", app.getFitbitStatusHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/integrations/fitbit/disconnect", app.fitbitDisconnectHandler).Methods("DELETE", "OPTIONS")
|
||||
protected.HandleFunc("/api/integrations/fitbit/goals", app.updateFitbitGoalsHandler).Methods("PUT", "OPTIONS")
|
||||
protected.HandleFunc("/api/integrations/fitbit/sync", app.fitbitSyncHandler).Methods("POST", "OPTIONS")
|
||||
protected.HandleFunc("/api/integrations/fitbit/stats", app.getFitbitStatsHandler).Methods("GET", "OPTIONS")
|
||||
|
||||
// Tasks
|
||||
protected.HandleFunc("/api/tasks", app.getTasksHandler).Methods("GET", "OPTIONS")
|
||||
protected.HandleFunc("/api/tasks", app.createTaskHandler).Methods("POST", "OPTIONS")
|
||||
@@ -10059,6 +10137,732 @@ func (a *App) todoistDisconnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Fitbit OAuth handlers
|
||||
// ============================================
|
||||
|
||||
// generateFitbitOAuthState генерирует JWT state для Fitbit OAuth
|
||||
func generateFitbitOAuthState(userID int, jwtSecret []byte) (string, error) {
|
||||
claims := OAuthStateClaims{
|
||||
UserID: userID,
|
||||
Type: "fitbit_oauth",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), // 1 день
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
// validateFitbitOAuthState проверяет и извлекает user_id из JWT state для Fitbit
|
||||
func validateFitbitOAuthState(stateString string, jwtSecret []byte) (int, error) {
|
||||
token, err := jwt.ParseWithClaims(stateString, &OAuthStateClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return jwtSecret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*OAuthStateClaims)
|
||||
if !ok || !token.Valid {
|
||||
return 0, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
if claims.Type != "fitbit_oauth" {
|
||||
return 0, fmt.Errorf("wrong token type")
|
||||
}
|
||||
|
||||
return claims.UserID, nil
|
||||
}
|
||||
|
||||
// exchangeFitbitCodeForToken обменивает OAuth code на access_token и refresh_token для Fitbit
|
||||
func exchangeFitbitCodeForToken(code, redirectURI, clientID, clientSecret string) (accessToken, refreshToken string, expiresIn int, err error) {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("code", code)
|
||||
data.Set("redirect_uri", redirectURI)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Fitbit требует Basic Auth для Server приложений
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", 0, fmt.Errorf("token exchange failed (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
UserID string `json:"user_id"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return "", "", 0, fmt.Errorf("token exchange error: %s - %s", result.Error, result.ErrorDesc)
|
||||
}
|
||||
|
||||
return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil
|
||||
}
|
||||
|
||||
// getFitbitUserInfo получает информацию о пользователе Fitbit
|
||||
func getFitbitUserInfo(accessToken string) (string, error) {
|
||||
req, err := http.NewRequest("GET", "https://api.fitbit.com/1/user/-/profile.json", nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user info: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("get user info failed (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
User struct {
|
||||
EncodedID string `json:"encodedId"`
|
||||
} `json:"user"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if result.User.EncodedID == "" {
|
||||
return "", fmt.Errorf("user ID not found in response")
|
||||
}
|
||||
|
||||
return result.User.EncodedID, nil
|
||||
}
|
||||
|
||||
// refreshFitbitToken обновляет access_token используя refresh_token
|
||||
func refreshFitbitToken(refreshToken, clientID, clientSecret string) (accessToken, newRefreshToken string, expiresIn int, err error) {
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "refresh_token")
|
||||
data.Set("refresh_token", refreshToken)
|
||||
|
||||
req, err := http.NewRequest("POST", "https://api.fitbit.com/oauth2/token", strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSecret))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", 0, fmt.Errorf("token refresh failed (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDesc string `json:"error_description"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if result.Error != "" {
|
||||
return "", "", 0, fmt.Errorf("token refresh error: %s - %s", result.Error, result.ErrorDesc)
|
||||
}
|
||||
|
||||
return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil
|
||||
}
|
||||
|
||||
// fitbitOAuthConnectHandler инициирует OAuth flow для Fitbit
|
||||
func (a *App) fitbitOAuthConnectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
setCORSHeaders(w)
|
||||
|
||||
userID, ok := getUserIDFromContext(r)
|
||||
if !ok {
|
||||
sendErrorWithCORS(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
clientID := getEnv("FITBIT_CLIENT_ID", "")
|
||||
clientSecret := getEnv("FITBIT_CLIENT_SECRET", "")
|
||||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||
|
||||
if clientID == "" || clientSecret == "" {
|
||||
sendErrorWithCORS(w, "FITBIT_CLIENT_ID and FITBIT_CLIENT_SECRET must be configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if baseURL == "" {
|
||||
sendErrorWithCORS(w, "WEBHOOK_BASE_URL must be configured", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/fitbit/oauth/callback"
|
||||
|
||||
state, err := generateFitbitOAuthState(userID, a.jwtSecret)
|
||||
if err != nil {
|
||||
log.Printf("Fitbit OAuth: failed to generate state: %v", err)
|
||||
sendErrorWithCORS(w, "Failed to generate OAuth state", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fitbit OAuth URL с необходимыми scopes
|
||||
authURL := fmt.Sprintf(
|
||||
"https://www.fitbit.com/oauth2/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=activity%%20profile&state=%s",
|
||||
url.QueryEscape(clientID),
|
||||
url.QueryEscape(redirectURI),
|
||||
url.QueryEscape(state),
|
||||
)
|
||||
|
||||
log.Printf("Fitbit OAuth: returning auth URL for user_id=%d", userID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"auth_url": authURL,
|
||||
})
|
||||
}
|
||||
|
||||
// fitbitOAuthCallbackHandler обрабатывает OAuth callback от Fitbit
|
||||
func (a *App) fitbitOAuthCallbackHandler(w http.ResponseWriter, r *http.Request) {
|
||||
frontendURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||
redirectSuccess := frontendURL + "/?integration=fitbit&status=connected"
|
||||
redirectError := frontendURL + "/?integration=fitbit&status=error"
|
||||
|
||||
clientID := getEnv("FITBIT_CLIENT_ID", "")
|
||||
clientSecret := getEnv("FITBIT_CLIENT_SECRET", "")
|
||||
baseURL := getEnv("WEBHOOK_BASE_URL", "")
|
||||
|
||||
if clientID == "" || clientSecret == "" || baseURL == "" {
|
||||
log.Printf("Fitbit OAuth: missing configuration")
|
||||
http.Redirect(w, r, redirectError+"&message=config_error", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURI := strings.TrimRight(baseURL, "/") + "/api/integrations/fitbit/oauth/callback"
|
||||
|
||||
// Проверяем state
|
||||
state := r.URL.Query().Get("state")
|
||||
userID, err := validateFitbitOAuthState(state, a.jwtSecret)
|
||||
if err != nil {
|
||||
log.Printf("Fitbit 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("Fitbit OAuth: no code in callback")
|
||||
http.Redirect(w, r, redirectError+"&message=no_code", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Обмениваем code на токены
|
||||
accessToken, refreshToken, expiresIn, err := exchangeFitbitCodeForToken(code, redirectURI, clientID, clientSecret)
|
||||
if err != nil {
|
||||
log.Printf("Fitbit OAuth: token exchange failed: %v", err)
|
||||
http.Redirect(w, r, redirectError+"&message=token_exchange_failed", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем информацию о пользователе
|
||||
fitbitUserID, err := getFitbitUserInfo(accessToken)
|
||||
if err != nil {
|
||||
log.Printf("Fitbit OAuth: get user info failed: %v", err)
|
||||
http.Redirect(w, r, redirectError+"&message=user_info_failed", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Fitbit OAuth: user_id=%d connected fitbit_user_id=%s", userID, fitbitUserID)
|
||||
|
||||
// Вычисляем время истечения токена
|
||||
tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
// Сохраняем в БД
|
||||
_, err = a.DB.Exec(`
|
||||
INSERT INTO fitbit_integrations (user_id, fitbit_user_id, access_token, refresh_token, token_expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
fitbit_user_id = $2,
|
||||
access_token = $3,
|
||||
refresh_token = $4,
|
||||
token_expires_at = $5,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, userID, fitbitUserID, accessToken, refreshToken, tokenExpiresAt)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Fitbit OAuth: DB error: %v", err)
|
||||
http.Redirect(w, r, redirectError+"&message=db_error", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Редирект на страницу интеграций
|
||||
http.Redirect(w, r, redirectSuccess, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// getFitbitStatusHandler возвращает статус подключения Fitbit
|
||||
func (a *App) getFitbitStatusHandler(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
|
||||
}
|
||||
|
||||
var fitbitUserID sql.NullString
|
||||
var goalStepsMin, goalStepsMax, goalFloorsMin, goalFloorsMax, goalAzmMin, goalAzmMax sql.NullInt64
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT fitbit_user_id, goal_steps_min, goal_steps_max, goal_floors_min, goal_floors_max, goal_azm_min, goal_azm_max
|
||||
FROM fitbit_integrations
|
||||
WHERE user_id = $1 AND access_token IS NOT NULL
|
||||
`, userID).Scan(&fitbitUserID, &goalStepsMin, &goalStepsMax, &goalFloorsMin, &goalFloorsMax, &goalAzmMin, &goalAzmMax)
|
||||
|
||||
if err == sql.ErrNoRows || !fitbitUserID.Valid {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"connected": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Failed to get status: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"connected": true,
|
||||
"goals": map[string]interface{}{
|
||||
"steps": map[string]interface{}{
|
||||
"min": goalStepsMin.Int64,
|
||||
"max": goalStepsMax.Int64,
|
||||
},
|
||||
"floors": map[string]interface{}{
|
||||
"min": goalFloorsMin.Int64,
|
||||
"max": goalFloorsMax.Int64,
|
||||
},
|
||||
"azm": map[string]interface{}{
|
||||
"min": goalAzmMin.Int64,
|
||||
"max": goalAzmMax.Int64,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// fitbitDisconnectHandler отключает интеграцию Fitbit
|
||||
func (a *App) fitbitDisconnectHandler(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 fitbit_integrations WHERE user_id = $1
|
||||
`, userID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Fitbit disconnect: DB error: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Failed to disconnect: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Fitbit disconnected for user_id=%d", userID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Fitbit disconnected",
|
||||
})
|
||||
}
|
||||
|
||||
// updateFitbitGoalsHandler обновляет цели пользователя
|
||||
func (a *App) updateFitbitGoalsHandler(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
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Steps map[string]int64 `json:"steps"`
|
||||
Floors map[string]int64 `json:"floors"`
|
||||
Azm map[string]int64 `json:"azm"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := a.DB.Exec(`
|
||||
UPDATE fitbit_integrations
|
||||
SET goal_steps_min = $1, goal_steps_max = $2,
|
||||
goal_floors_min = $3, goal_floors_max = $4,
|
||||
goal_azm_min = $5, goal_azm_max = $6,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = $7
|
||||
`, req.Steps["min"], req.Steps["max"],
|
||||
req.Floors["min"], req.Floors["max"],
|
||||
req.Azm["min"], req.Azm["max"],
|
||||
userID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Fitbit update goals: DB error: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Failed to update goals: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Goals updated",
|
||||
})
|
||||
}
|
||||
|
||||
// getFitbitAccessToken получает актуальный access_token (обновляет если нужно)
|
||||
func (a *App) getFitbitAccessToken(userID int) (string, error) {
|
||||
var accessToken, refreshToken sql.NullString
|
||||
var tokenExpiresAt sql.NullTime
|
||||
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT access_token, refresh_token, token_expires_at
|
||||
FROM fitbit_integrations
|
||||
WHERE user_id = $1
|
||||
`, userID).Scan(&accessToken, &refreshToken, &tokenExpiresAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("fitbit integration not found")
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get tokens: %w", err)
|
||||
}
|
||||
|
||||
if !accessToken.Valid {
|
||||
return "", fmt.Errorf("access token not found")
|
||||
}
|
||||
|
||||
// Проверяем, не истек ли токен (с запасом 5 минут)
|
||||
if tokenExpiresAt.Valid && time.Now().Add(5*time.Minute).After(tokenExpiresAt.Time) {
|
||||
// Токен истек или скоро истечет, обновляем
|
||||
if !refreshToken.Valid {
|
||||
return "", fmt.Errorf("refresh token not found")
|
||||
}
|
||||
|
||||
clientID := getEnv("FITBIT_CLIENT_ID", "")
|
||||
clientSecret := getEnv("FITBIT_CLIENT_SECRET", "")
|
||||
if clientID == "" || clientSecret == "" {
|
||||
return "", fmt.Errorf("FITBIT_CLIENT_ID and FITBIT_CLIENT_SECRET must be configured")
|
||||
}
|
||||
|
||||
newAccessToken, newRefreshToken, expiresIn, err := refreshFitbitToken(refreshToken.String, clientID, clientSecret)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Обновляем токены в БД
|
||||
tokenExpiresAtNew := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||||
_, err = a.DB.Exec(`
|
||||
UPDATE fitbit_integrations
|
||||
SET access_token = $1, refresh_token = $2, token_expires_at = $3, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = $4
|
||||
`, newAccessToken, newRefreshToken, tokenExpiresAtNew, userID)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to update tokens: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Fitbit token refreshed for user_id=%d", userID)
|
||||
return newAccessToken, nil
|
||||
}
|
||||
|
||||
return accessToken.String, nil
|
||||
}
|
||||
|
||||
// syncFitbitData синхронизирует данные из Fitbit API для указанной даты
|
||||
func (a *App) syncFitbitData(userID int, date time.Time) error {
|
||||
accessToken, err := a.getFitbitAccessToken(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get access token: %w", err)
|
||||
}
|
||||
|
||||
dateStr := date.Format("2006-01-02")
|
||||
|
||||
// Получаем данные активности за день
|
||||
activityURL := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/date/%s.json", dateStr)
|
||||
req, err := http.NewRequest("GET", activityURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get activity data: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("get activity data failed (status %d): %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var activityData struct {
|
||||
Summary struct {
|
||||
Steps int `json:"steps"`
|
||||
Floors int `json:"floors"`
|
||||
} `json:"summary"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytes, &activityData); err != nil {
|
||||
return fmt.Errorf("failed to decode activity data: %w", err)
|
||||
}
|
||||
|
||||
// Получаем Active Zone Minutes
|
||||
azmURL := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/active-zone-minutes/date/%s/1d.json", dateStr)
|
||||
reqAZM, err := http.NewRequest("GET", azmURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create AZM request: %w", err)
|
||||
}
|
||||
|
||||
reqAZM.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
reqAZM.Header.Set("Accept", "application/json")
|
||||
|
||||
respAZM, err := client.Do(reqAZM)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get AZM data: %w", err)
|
||||
}
|
||||
defer respAZM.Body.Close()
|
||||
|
||||
bodyBytesAZM, _ := io.ReadAll(respAZM.Body)
|
||||
|
||||
var azmValue int
|
||||
if respAZM.StatusCode == http.StatusOK {
|
||||
var azmData struct {
|
||||
ActivitiesActiveZoneMinutes []struct {
|
||||
Value struct {
|
||||
ActiveZoneMinutes int `json:"activeZoneMinutes"`
|
||||
} `json:"value"`
|
||||
} `json:"activities-active-zone-minutes"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(bodyBytesAZM, &azmData); err == nil {
|
||||
if len(azmData.ActivitiesActiveZoneMinutes) > 0 {
|
||||
azmValue = azmData.ActivitiesActiveZoneMinutes[0].Value.ActiveZoneMinutes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем данные в БД
|
||||
_, err = a.DB.Exec(`
|
||||
INSERT INTO fitbit_daily_stats (user_id, date, steps, floors, active_zone_minutes, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (user_id, date) DO UPDATE SET
|
||||
steps = $3,
|
||||
floors = $4,
|
||||
active_zone_minutes = $5,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`, userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, azmValue)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save stats: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("Fitbit data synced for user_id=%d, date=%s: steps=%d, floors=%d, azm=%d",
|
||||
userID, dateStr, activityData.Summary.Steps, activityData.Summary.Floors, azmValue)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fitbitSyncHandler выполняет ручную синхронизацию данных Fitbit
|
||||
func (a *App) fitbitSyncHandler(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.syncFitbitData(userID, time.Now())
|
||||
if err != nil {
|
||||
log.Printf("Fitbit sync error: %v", err)
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Sync failed: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Data synced successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// getFitbitStatsHandler возвращает статистику Fitbit за указанную дату
|
||||
func (a *App) getFitbitStatsHandler(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
|
||||
}
|
||||
|
||||
// Получаем дату из query параметра (по умолчанию сегодня)
|
||||
dateStr := r.URL.Query().Get("date")
|
||||
if dateStr == "" {
|
||||
dateStr = time.Now().Format("2006-01-02")
|
||||
}
|
||||
|
||||
var steps, floors, azm sql.NullInt64
|
||||
err := a.DB.QueryRow(`
|
||||
SELECT steps, floors, active_zone_minutes
|
||||
FROM fitbit_daily_stats
|
||||
WHERE user_id = $1 AND date = $2
|
||||
`, userID, dateStr).Scan(&steps, &floors, &azm)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
// Данных нет, возвращаем нули
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"date": dateStr,
|
||||
"steps": 0,
|
||||
"floors": 0,
|
||||
"azm": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
sendErrorWithCORS(w, fmt.Sprintf("Failed to get stats: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем цели пользователя
|
||||
var goalStepsMin, goalStepsMax, goalFloorsMin, goalFloorsMax, goalAzmMin, goalAzmMax sql.NullInt64
|
||||
err = a.DB.QueryRow(`
|
||||
SELECT goal_steps_min, goal_steps_max, goal_floors_min, goal_floors_max, goal_azm_min, goal_azm_max
|
||||
FROM fitbit_integrations
|
||||
WHERE user_id = $1
|
||||
`, userID).Scan(&goalStepsMin, &goalStepsMax, &goalFloorsMin, &goalFloorsMax, &goalAzmMin, &goalAzmMax)
|
||||
|
||||
if err != nil {
|
||||
// Если целей нет, используем значения по умолчанию
|
||||
goalStepsMin = sql.NullInt64{Int64: 8000, Valid: true}
|
||||
goalStepsMax = sql.NullInt64{Int64: 10000, Valid: true}
|
||||
goalFloorsMin = sql.NullInt64{Int64: 8, Valid: true}
|
||||
goalFloorsMax = sql.NullInt64{Int64: 10, Valid: true}
|
||||
goalAzmMin = sql.NullInt64{Int64: 22, Valid: true}
|
||||
goalAzmMax = sql.NullInt64{Int64: 44, Valid: true}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"date": dateStr,
|
||||
"steps": map[string]interface{}{
|
||||
"value": steps.Int64,
|
||||
"goal": map[string]interface{}{
|
||||
"min": goalStepsMin.Int64,
|
||||
"max": goalStepsMax.Int64,
|
||||
},
|
||||
},
|
||||
"floors": map[string]interface{}{
|
||||
"value": floors.Int64,
|
||||
"goal": map[string]interface{}{
|
||||
"min": goalFloorsMin.Int64,
|
||||
"max": goalFloorsMax.Int64,
|
||||
},
|
||||
},
|
||||
"azm": map[string]interface{}{
|
||||
"value": azm.Int64,
|
||||
"goal": map[string]interface{}{
|
||||
"min": goalAzmMin.Int64,
|
||||
"max": goalAzmMax.Int64,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Wishlist handlers
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user