Skip to content

Commit e5bcba4

Browse files
ervwalterAssistant
andcommitted
✨ feat: add Fitbit integration with OAuth flow and data sync
- Implement complete Fitbit OAuth 2.0 authentication flow - Add Fitbit weight data synchronization with automatic kg conversion - Refactor provider token storage to use Dictionary<string,object> pattern - Implement rate limiting with automatic request throttling - Handle Fitbit API limitations (32-day chunks, 2009-01-01 minimum date) - Add proper provider disconnection that deletes both link and source data - Update frontend to support Fitbit provider in link page - Fix toast notifications to properly display success/error messages - Add comprehensive error handling and logging for Fitbit API - Update documentation with Fitbit setup instructions Co-Authored-By: Assistant <assistant@anthropic.com>
1 parent eed76eb commit e5bcba4

40 files changed

Lines changed: 1586 additions & 267 deletions

CLAUDE.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,17 @@ VITE_SUPABASE_ANON_KEY=[anon-key]
126126
},
127127
"Withings": {
128128
"ClientId": "[client-id]",
129-
"ClientSecret": "[client-secret]",
130-
"RedirectUri": "http://localhost:5173/oauth/withings/callback"
129+
"ClientSecret": "[client-secret]"
130+
},
131+
"Fitbit": {
132+
"ClientId": "[client-id]",
133+
"ClientSecret": "[client-secret]"
131134
}
132135
}
133136
```
134137

138+
**Note**: OAuth redirect URLs are constructed dynamically using `Request.Scheme` and `Request.Host` to support different deployment environments. Never hardcode URLs in configuration.
139+
135140
## Database Schema (Supabase)
136141

137142
### Tables
@@ -184,6 +189,7 @@ VITE_SUPABASE_ANON_KEY=[anon-key]
184189

185190
### OAuth Callback Routes
186191
- `/oauth/withings/callback` - Withings OAuth callback handler
192+
- `/oauth/fitbit/callback` - Fitbit OAuth callback handler
187193

188194
## Authentication
189195

@@ -431,6 +437,12 @@ The containerized application:
431437
3. **Respect user's timezone** - All date operations must be timezone-aware
432438
4. **Test on mobile** - The app must be fully responsive
433439
5. **Keep accessibility in mind** - Use semantic HTML and ARIA labels
440+
6. **Provider disconnection** - When a user disconnects a provider, both the provider link AND the associated source data are deleted to ensure clean state
441+
7. **Fitbit API limitations**:
442+
- Weight data requests cannot start before 2009-01-01
443+
- Maximum date range per request is 32 days (not 31 or 33)
444+
- Body-weight endpoint may return extrapolated data, not actual measurements
445+
- Always use initial sync date of 2009-01-01 for Fitbit
434446

435447
# important-instruction-reminders
436448
Do what has been asked; nothing more, nothing less.

PROJECT_STATUS.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,22 @@ This issues tracks the implementation status of TrendWeight migration
5656
- [x] Data access layer in backend
5757
- [x] Type-safe API client
5858

59-
### ⚙️ Provider Integrations
59+
### Provider Integrations
6060

6161
- [x] Withings OAuth flow
6262
- [x] Withings data sync
6363
- [x] Withings token refresh
6464
- [x] Provider link management API
65-
- [ ] Fitbit OAuth flow
66-
- [ ] Fitbit data sync
67-
- [ ] Fitbit token refresh
65+
- [x] Fitbit OAuth flow
66+
- [x] Fitbit data sync (with pounds-to-kg conversion)
67+
- [x] Fitbit token refresh
68+
- [x] Fitbit rate limiting (automatic handling with delays)
69+
- [x] Fitbit date range handling (32-day chunks, 2009-01-01 minimum)
6870
- [x] OAuth callback handling
6971
- [ ] OAuth error handling/recovery
70-
- [ ] Bulk historical data import
72+
- [x] Bulk historical data import (Fitbit fetches all available data)
7173
- [x] Manual data resync (with resync_requested flag for resilient handling)
74+
- [x] Provider disconnection (removes both link and source data)
7275

7376
### ✅ Dashboard & Visualization
7477

README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ TrendWeight uses a modern web architecture:
2222
- **Backend**: C# ASP.NET Core Web API
2323
- Supabase (PostgreSQL) for data storage
2424
- JWT-based authentication
25-
- Provider integrations for Withings and Fitbit
25+
- Provider integrations for Withings and Fitbit (NEW)
2626

2727
## Getting Started
2828

@@ -76,6 +76,11 @@ TrendWeight uses a modern web architecture:
7676
"Withings": {
7777
"ClientId": "your-withings-client-id",
7878
"ClientSecret": "your-withings-client-secret"
79+
},
80+
"Fitbit": {
81+
"ClientId": "your-fitbit-client-id",
82+
"ClientSecret": "your-fitbit-client-secret",
83+
"RedirectUri": "http://localhost:5173/oauth/fitbit/callback"
7984
}
8085
}
8186
```
@@ -156,6 +161,8 @@ The application uses different environment variables at build time vs runtime:
156161
- `Supabase__JwtSecret` - Your Supabase JWT secret
157162
- `Withings__ClientId` - Withings OAuth client ID
158163
- `Withings__ClientSecret` - Withings OAuth client secret
164+
- `Fitbit__ClientId` - Fitbit OAuth client ID
165+
- `Fitbit__ClientSecret` - Fitbit OAuth client secret
159166
- `Jwt__SigningKey` - JWT signing key for the API
160167

161168
### GitHub Actions CI/CD
@@ -198,8 +205,10 @@ docker run -d \
198205
-e Supabase__AnonKey="your-anon-key" \
199206
-e Supabase__ServiceKey="your-service-key" \
200207
-e Supabase__JwtSecret="your-jwt-secret" \
201-
-e Withings__ClientId="your-client-id" \
202-
-e Withings__ClientSecret="your-client-secret" \
208+
-e Withings__ClientId="your-withings-client-id" \
209+
-e Withings__ClientSecret="your-withings-client-secret" \
210+
-e Fitbit__ClientId="your-fitbit-client-id" \
211+
-e Fitbit__ClientSecret="your-fitbit-client-secret" \
203212
-e Jwt__SigningKey="your-signing-key" \
204213
ghcr.io/[your-username]/trendweight:latest
205214
```

apps/api/TrendWeight/Features/Measurements/ISourceDataService.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,11 @@ public interface ISourceDataService
4747
/// <param name="provider">Provider name</param>
4848
/// <returns>True if resync is requested</returns>
4949
Task<bool> IsResyncRequestedAsync(Guid userId, string provider);
50+
51+
/// <summary>
52+
/// Deletes source data for a user
53+
/// </summary>
54+
/// <param name="userId">User's Supabase UID</param>
55+
/// <param name="provider">Provider name to delete specific provider data</param>
56+
Task DeleteSourceDataAsync(Guid userId, string provider);
5057
}

apps/api/TrendWeight/Features/Measurements/SourceDataService.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,4 +317,26 @@ private bool AreMeasurementsEqual(List<RawMeasurement> list1, List<RawMeasuremen
317317

318318
return true;
319319
}
320+
321+
/// <inheritdoc />
322+
public async Task DeleteSourceDataAsync(Guid userId, string provider)
323+
{
324+
try
325+
{
326+
var sourceData = await _supabaseService.QueryAsync<DbSourceData>(q =>
327+
q.Where(sd => sd.Uid == userId && sd.Provider == provider));
328+
329+
var data = sourceData.FirstOrDefault();
330+
if (data != null)
331+
{
332+
await _supabaseService.DeleteAsync<DbSourceData>(data);
333+
_logger.LogInformation("Deleted source data row for user {UserId} provider {Provider}", userId, provider);
334+
}
335+
}
336+
catch (Exception ex)
337+
{
338+
_logger.LogError(ex, "Error deleting source data for user {UserId} provider {Provider}", userId, provider);
339+
throw;
340+
}
341+
}
320342
}

apps/api/TrendWeight/Features/ProviderLinks/Services/IProviderLinkService.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using TrendWeight.Features.Providers.Models;
21
using DbProviderLink = TrendWeight.Infrastructure.DataAccess.Models.DbProviderLink;
32

43
namespace TrendWeight.Features.ProviderLinks.Services;
@@ -12,5 +11,5 @@ public interface IProviderLinkService
1211
Task<DbProviderLink> UpdateAsync(DbProviderLink providerLink);
1312
Task DeleteAsync(Guid uid, string provider);
1413
Task RemoveProviderLinkAsync(Guid uid, string provider);
15-
Task StoreProviderLinkAsync(Guid uid, string provider, AccessToken token, string? updateReason = null);
14+
Task StoreProviderLinkAsync(Guid uid, string provider, Dictionary<string, object> token, string? updateReason = null);
1615
}

apps/api/TrendWeight/Features/ProviderLinks/Services/ProviderLinkService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1+
using System.Text.Json;
12
using TrendWeight.Infrastructure.DataAccess;
23
using TrendWeight.Infrastructure.DataAccess.Models;
3-
using TrendWeight.Features.Providers.Models;
44

55
namespace TrendWeight.Features.ProviderLinks.Services;
66

@@ -79,7 +79,7 @@ public Task RemoveProviderLinkAsync(Guid uid, string provider)
7979
return DeleteAsync(uid, provider);
8080
}
8181

82-
public async Task StoreProviderLinkAsync(Guid uid, string provider, AccessToken token, string? updateReason = null)
82+
public async Task StoreProviderLinkAsync(Guid uid, string provider, Dictionary<string, object> token, string? updateReason = null)
8383
{
8484
var existingLink = await GetAsync(uid, provider);
8585

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using System.IdentityModel.Tokens.Jwt;
2+
using System.Text;
3+
using Microsoft.AspNetCore.Authorization;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.IdentityModel.Tokens;
6+
using TrendWeight.Infrastructure.Auth;
7+
8+
namespace TrendWeight.Features.Providers.Fitbit;
9+
10+
/// <summary>
11+
/// Controller for handling Fitbit OAuth callbacks
12+
/// </summary>
13+
[ApiController]
14+
[Route("api/fitbit")]
15+
[AllowAnonymous]
16+
public class FitbitCallbackController : ControllerBase
17+
{
18+
private readonly IFitbitService _fitbitService;
19+
private readonly FitbitConfig _config;
20+
private readonly IConfiguration _configuration;
21+
private readonly ILogger<FitbitCallbackController> _logger;
22+
23+
/// <summary>
24+
/// Constructor
25+
/// </summary>
26+
public FitbitCallbackController(
27+
IFitbitService fitbitService,
28+
FitbitConfig config,
29+
IConfiguration configuration,
30+
ILogger<FitbitCallbackController> logger)
31+
{
32+
_fitbitService = fitbitService;
33+
_config = config;
34+
_configuration = configuration;
35+
_logger = logger;
36+
}
37+
38+
/// <summary>
39+
/// Handles OAuth callback from Fitbit
40+
/// </summary>
41+
[HttpGet("callback")]
42+
public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] string state)
43+
{
44+
try
45+
{
46+
// Get JWT signing key
47+
var jwtSigningKey = _configuration["Jwt:SigningKey"];
48+
if (string.IsNullOrEmpty(jwtSigningKey))
49+
{
50+
_logger.LogError("JWT signing key not configured");
51+
return StatusCode(500, new { error = "JWT signing key not configured" });
52+
}
53+
54+
// Validate state token
55+
var tokenHandler = new JwtSecurityTokenHandler();
56+
var key = Encoding.ASCII.GetBytes(jwtSigningKey);
57+
58+
var validationParameters = new TokenValidationParameters
59+
{
60+
ValidateIssuerSigningKey = true,
61+
IssuerSigningKey = new SymmetricSecurityKey(key),
62+
ValidateIssuer = false,
63+
ValidateAudience = false,
64+
ValidateLifetime = true,
65+
ClockSkew = TimeSpan.Zero
66+
};
67+
68+
var principal = tokenHandler.ValidateToken(state, validationParameters, out var validatedToken);
69+
70+
var uid = principal.FindFirst("uid")?.Value;
71+
var reason = principal.FindFirst("reason")?.Value;
72+
73+
if (string.IsNullOrEmpty(uid) || string.IsNullOrEmpty(reason))
74+
{
75+
throw new SecurityTokenException("Invalid state token");
76+
}
77+
78+
if (!Guid.TryParse(uid, out var userId))
79+
{
80+
_logger.LogWarning("Invalid user ID in state token");
81+
return BadRequest("Invalid user ID");
82+
}
83+
84+
// Exchange code for tokens
85+
// ForwardedHeaders middleware has already updated Request.Scheme and Request.Host
86+
var callbackUrl = $"{Request.Scheme}://{Request.Host}/api/fitbit/callback";
87+
88+
_logger.LogDebug("Exchanging code for token with callback URL: {CallbackUrl}", callbackUrl);
89+
90+
var success = await _fitbitService.ExchangeAuthorizationCodeAsync(code, callbackUrl, userId);
91+
92+
if (!success)
93+
{
94+
_logger.LogError("Failed to exchange Fitbit authorization code");
95+
return BadRequest("Failed to complete authorization");
96+
}
97+
98+
// Redirect to frontend success page
99+
var redirectUrl = $"{Request.Scheme}://{Request.Host}/oauth/fitbit/callback?success=true";
100+
101+
_logger.LogDebug("Redirecting to: {RedirectUrl}", redirectUrl);
102+
103+
return Redirect(redirectUrl);
104+
}
105+
catch (SecurityTokenExpiredException)
106+
{
107+
_logger.LogWarning("State token expired");
108+
return BadRequest("Authorization expired. Please try again.");
109+
}
110+
catch (Exception ex)
111+
{
112+
_logger.LogError(ex, "Error handling Fitbit callback");
113+
114+
// Redirect to frontend error page
115+
return Redirect($"{Request.Scheme}://{Request.Host}/oauth/fitbit/callback?success=false&error={Uri.EscapeDataString(ex.Message)}");
116+
}
117+
}
118+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace TrendWeight.Features.Providers.Fitbit;
2+
3+
/// <summary>
4+
/// Configuration for Fitbit OAuth
5+
/// </summary>
6+
public class FitbitConfig
7+
{
8+
/// <summary>
9+
/// Fitbit OAuth client ID
10+
/// </summary>
11+
public string ClientId { get; set; } = string.Empty;
12+
13+
/// <summary>
14+
/// Fitbit OAuth client secret
15+
/// </summary>
16+
public string ClientSecret { get; set; } = string.Empty;
17+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System.IdentityModel.Tokens.Jwt;
2+
using System.Security.Claims;
3+
using System.Text;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.IdentityModel.Tokens;
6+
using TrendWeight.Features.Common;
7+
8+
namespace TrendWeight.Features.Providers.Fitbit;
9+
10+
/// <summary>
11+
/// Controller for initiating Fitbit OAuth flow
12+
/// </summary>
13+
[ApiController]
14+
[Route("api/fitbit")]
15+
public class FitbitLinkController : BaseAuthController
16+
{
17+
private readonly IFitbitService _fitbitService;
18+
private readonly FitbitConfig _config;
19+
private readonly IConfiguration _configuration;
20+
private readonly ILogger<FitbitLinkController> _logger;
21+
22+
/// <summary>
23+
/// Constructor
24+
/// </summary>
25+
public FitbitLinkController(
26+
IFitbitService fitbitService,
27+
FitbitConfig config,
28+
IConfiguration configuration,
29+
ILogger<FitbitLinkController> logger)
30+
{
31+
_fitbitService = fitbitService;
32+
_config = config;
33+
_configuration = configuration;
34+
_logger = logger;
35+
}
36+
37+
/// <summary>
38+
/// Initiates Fitbit OAuth flow
39+
/// </summary>
40+
[HttpGet("link")]
41+
public IActionResult LinkFitbit()
42+
{
43+
// Get JWT signing key
44+
var jwtSigningKey = _configuration["Jwt:SigningKey"];
45+
if (string.IsNullOrEmpty(jwtSigningKey))
46+
{
47+
_logger.LogError("JWT signing key not configured");
48+
return StatusCode(500, new { error = "JWT signing key not configured" });
49+
}
50+
51+
// Generate state token with user ID and expiration
52+
var tokenHandler = new JwtSecurityTokenHandler();
53+
var key = Encoding.ASCII.GetBytes(jwtSigningKey);
54+
55+
var tokenDescriptor = new SecurityTokenDescriptor
56+
{
57+
Subject = new ClaimsIdentity(new[]
58+
{
59+
new Claim("uid", UserId),
60+
new Claim("reason", "link")
61+
}),
62+
Expires = DateTime.UtcNow.AddHours(1),
63+
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
64+
};
65+
66+
var token = tokenHandler.CreateToken(tokenDescriptor);
67+
var state = tokenHandler.WriteToken(token);
68+
69+
// Get callback URL - ForwardedHeaders middleware has already updated Request.Scheme and Request.Host
70+
var callbackUrl = $"{Request.Scheme}://{Request.Host}/api/fitbit/callback";
71+
72+
_logger.LogInformation("Using callback URL: {CallbackUrl}", callbackUrl);
73+
74+
// Get authorization URL
75+
var authUrl = _fitbitService.GetAuthorizationUrl(state, callbackUrl);
76+
77+
_logger.LogInformation("Generated authorization URL: {AuthorizationUrl}", authUrl);
78+
79+
return Ok(new { url = authUrl });
80+
}
81+
}

0 commit comments

Comments
 (0)