Добавлена интеграция с Fitbit
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m25s

This commit is contained in:
poignatov
2026-02-06 20:50:49 +03:00
parent f1c590de43
commit dfccba4e55
13 changed files with 1711 additions and 21 deletions

View File

@@ -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
// ============================================