@nativesquare/soma 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/client/garmin.d.ts +291 -0
  2. package/dist/client/garmin.d.ts.map +1 -0
  3. package/dist/client/garmin.js +493 -0
  4. package/dist/client/garmin.js.map +1 -0
  5. package/dist/client/index.d.ts +29 -394
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +30 -520
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/client/strava.d.ts +97 -0
  10. package/dist/client/strava.d.ts.map +1 -0
  11. package/dist/client/strava.js +160 -0
  12. package/dist/client/strava.js.map +1 -0
  13. package/dist/client/types.d.ts +238 -0
  14. package/dist/client/types.d.ts.map +1 -1
  15. package/dist/component/_generated/component.d.ts +24 -12
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/garmin/private.d.ts +53 -68
  18. package/dist/component/garmin/private.d.ts.map +1 -1
  19. package/dist/component/garmin/private.js +87 -85
  20. package/dist/component/garmin/private.js.map +1 -1
  21. package/dist/component/garmin/public.d.ts +97 -53
  22. package/dist/component/garmin/public.d.ts.map +1 -1
  23. package/dist/component/garmin/public.js +75 -148
  24. package/dist/component/garmin/public.js.map +1 -1
  25. package/dist/component/garmin/webhooks.d.ts +22 -20
  26. package/dist/component/garmin/webhooks.d.ts.map +1 -1
  27. package/dist/component/garmin/webhooks.js +115 -76
  28. package/dist/component/garmin/webhooks.js.map +1 -1
  29. package/dist/component/public.d.ts +15 -15
  30. package/dist/component/schema.d.ts +25 -25
  31. package/dist/component/strava/public.d.ts +12 -8
  32. package/dist/component/strava/public.d.ts.map +1 -1
  33. package/dist/component/strava/public.js +7 -7
  34. package/dist/component/strava/public.js.map +1 -1
  35. package/dist/component/validators/activity.d.ts +4 -4
  36. package/dist/component/validators/body.d.ts +4 -4
  37. package/dist/component/validators/daily.d.ts +4 -4
  38. package/dist/component/validators/nutrition.d.ts +3 -3
  39. package/dist/component/validators/samples.d.ts +4 -4
  40. package/dist/component/validators/shared.d.ts +13 -4
  41. package/dist/component/validators/shared.d.ts.map +1 -1
  42. package/dist/component/validators/shared.js +7 -0
  43. package/dist/component/validators/shared.js.map +1 -1
  44. package/dist/component/validators/sleep.d.ts +5 -5
  45. package/dist/validators.d.ts +41 -40
  46. package/dist/validators.d.ts.map +1 -1
  47. package/dist/validators.js +1 -0
  48. package/dist/validators.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/client/garmin.ts +692 -0
  51. package/src/client/index.ts +68 -933
  52. package/src/client/strava.ts +199 -0
  53. package/src/client/types.ts +285 -0
  54. package/src/component/_generated/component.ts +19 -32
  55. package/src/component/garmin/private.ts +1872 -1870
  56. package/src/component/garmin/public.ts +1073 -1184
  57. package/src/component/garmin/webhooks.ts +898 -857
  58. package/src/component/strava/public.ts +393 -393
  59. package/src/component/validators/shared.ts +9 -0
  60. package/src/validators.ts +1 -0
@@ -1,393 +1,393 @@
1
- // ─── Strava Component Actions ────────────────────────────────────────────────
2
- // Public actions that handle the full Strava OAuth + sync lifecycle.
3
- // The host app calls these through the Soma class, which threads the
4
- // credentials automatically from env vars or constructor config.
5
-
6
- import { v } from "convex/values";
7
- import { action } from "../_generated/server";
8
- import { api, internal } from "../_generated/api";
9
- import type { Doc, Id } from "../_generated/dataModel";
10
- import { createStravaClient } from "./client.js";
11
- import { listAllActivities } from "./utils.js";
12
- import {
13
- getLoggedInAthlete,
14
- getActivityById,
15
- getActivityStreams,
16
- } from "./types/stravaApi/sdk.gen.js";
17
- import { generateState } from "../utils.js";
18
- import {
19
- buildAuthUrl,
20
- exchangeCode,
21
- refreshToken as refreshStravaToken,
22
- } from "./auth.js";
23
- import { transformActivity } from "./transform/activity.js";
24
- import { transformAthlete } from "./transform/athlete.js";
25
-
26
- // ─── Public Actions ──────────────────────────────────────────────────────────
27
-
28
- /**
29
- * Generate a Strava OAuth authorization URL.
30
- *
31
- * The state parameter is stored in the component's `pendingOAuth` table
32
- * so that `completeStravaOAuth` can look it up automatically when the
33
- * callback fires via `registerRoutes`.
34
- */
35
- export const getStravaAuthUrl = action({
36
- args: {
37
- clientId: v.string(),
38
- redirectUri: v.string(),
39
- scope: v.optional(v.string()),
40
- userId: v.string(),
41
- },
42
- handler: async (ctx, args) => {
43
- const state = generateState();
44
-
45
- const authUrl = buildAuthUrl({
46
- clientId: args.clientId,
47
- redirectUri: args.redirectUri,
48
- scope: args.scope,
49
- state,
50
- });
51
-
52
- await ctx.runMutation(internal.strava.private.storePendingOAuth, {
53
- provider: "STRAVA",
54
- state,
55
- userId: args.userId,
56
- });
57
-
58
- return { authUrl, state };
59
- },
60
- });
61
-
62
- /**
63
- * Complete a Strava OAuth flow using stored pending state.
64
- *
65
- * Called internally by `registerRoutes` — the callback handler calls
66
- * this with the `code` and `state` from the redirect. The action looks
67
- * up the pending state (userId) stored during `getStravaAuthUrl`,
68
- * exchanges for tokens, creates the connection, stores tokens, and
69
- * cleans up the pending entry.
70
- *
71
- * The host app is responsible for calling `syncStrava` afterwards.
72
- */
73
- export const completeStravaOAuth = action({
74
- args: {
75
- code: v.string(),
76
- state: v.string(),
77
- clientId: v.string(),
78
- clientSecret: v.string(),
79
- },
80
- returns: v.object({
81
- connectionId: v.string(),
82
- userId: v.string(),
83
- }),
84
- handler: async (ctx, args): Promise<{
85
- connectionId: Id<"connections">;
86
- userId: string;
87
- }> => {
88
- // 1. Look up pending state
89
- const pending: Doc<"pendingOAuth"> | null = await ctx.runQuery(
90
- internal.strava.private.getPendingOAuth,
91
- { state: args.state },
92
- );
93
- if (!pending) {
94
- throw new Error(
95
- "No pending Strava OAuth state found for this state parameter. " +
96
- "The authorization may have expired or was already used.",
97
- );
98
- }
99
-
100
- // 2. Exchange authorization code for tokens
101
- const tokens = await exchangeCode({
102
- clientId: args.clientId,
103
- clientSecret: args.clientSecret,
104
- code: args.code,
105
- });
106
-
107
- // 3. Clean up pending entry
108
- await ctx.runMutation(
109
- internal.strava.private.deletePendingOAuth,
110
- { state: args.state },
111
- );
112
-
113
- // 4. Create/reactivate the Soma connection
114
- const connectionId: Id<"connections"> = await ctx.runMutation(
115
- api.public.connect,
116
- {
117
- userId: pending.userId,
118
- provider: "STRAVA",
119
- },
120
- );
121
-
122
- // 5. Store OAuth tokens
123
- await ctx.runMutation(
124
- internal.strava.private.storeTokens,
125
- {
126
- connectionId,
127
- accessToken: tokens.access_token,
128
- refreshToken: tokens.refresh_token,
129
- expiresAt: tokens.expires_at,
130
- },
131
- );
132
-
133
- return {
134
- connectionId,
135
- userId: pending.userId,
136
- };
137
- },
138
- });
139
-
140
- /**
141
- * Incremental Strava sync for an already-connected user.
142
- *
143
- * Looks up the stored tokens, auto-refreshes if expired, then syncs
144
- * the athlete profile and activities.
145
- *
146
- * Returns `{ synced, errors }`.
147
- */
148
- export const syncStrava = action({
149
- args: {
150
- userId: v.string(),
151
- clientId: v.string(),
152
- clientSecret: v.string(),
153
- after: v.optional(v.number()),
154
- },
155
- returns: v.object({
156
- synced: v.object({ athletes: v.number(), activities: v.number() }),
157
- errors: v.array(
158
- v.object({ type: v.string(), id: v.string(), error: v.string() }),
159
- ),
160
- }),
161
- handler: async (ctx, args): Promise<{
162
- synced: { athletes: number; activities: number };
163
- errors: Array<{ type: string; id: string; error: string }>;
164
- }> => {
165
- // 1. Look up connection
166
- const connection: Doc<"connections"> | null = await ctx.runQuery(
167
- internal.private.getConnectionByProvider,
168
- { userId: args.userId, provider: "STRAVA" },
169
- );
170
- if (!connection) {
171
- throw new Error(
172
- `No Strava connection found for user "${args.userId}". ` +
173
- "Connect to Strava first via getStravaAuthUrl.",
174
- );
175
- }
176
- if (!connection.active) {
177
- throw new Error(
178
- `Strava connection for user "${args.userId}" is inactive. Reconnect first.`,
179
- );
180
- }
181
-
182
- const connectionId = connection._id;
183
-
184
- // 2. Get stored tokens
185
- const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(
186
- internal.strava.private.getTokens,
187
- { connectionId },
188
- );
189
- if (!tokenDoc) {
190
- throw new Error(
191
- `No tokens found for Strava connection. ` +
192
- "The connection may have been created before token storage was available.",
193
- );
194
- }
195
-
196
- // 3. Auto-refresh if token is expired or expiring within 5 minutes
197
- let accessToken = tokenDoc.accessToken;
198
- const now = Math.floor(Date.now() / 1000);
199
- if (!tokenDoc.refreshToken || !tokenDoc.expiresAt) {
200
- throw new Error(
201
- "Strava tokens are missing refreshToken or expiresAt. " +
202
- "This connection may have been created with an incompatible version.",
203
- );
204
- }
205
- if (tokenDoc.expiresAt < now + 300) {
206
- const refreshed = await refreshStravaToken({
207
- clientId: args.clientId,
208
- clientSecret: args.clientSecret,
209
- refreshToken: tokenDoc.refreshToken,
210
- });
211
- accessToken = refreshed.access_token;
212
- await ctx.runMutation(
213
- internal.strava.private.storeTokens,
214
- {
215
- connectionId,
216
- accessToken: refreshed.access_token,
217
- refreshToken: refreshed.refresh_token,
218
- expiresAt: refreshed.expires_at,
219
- },
220
- );
221
- }
222
-
223
- // 4. Sync all data types
224
- const result = await ctx.runAction(api.strava.public.syncAllTypes, {
225
- accessToken,
226
- connectionId,
227
- userId: args.userId,
228
- after: args.after,
229
- });
230
-
231
- // 5. Update lastDataUpdate timestamp
232
- await ctx.runMutation(
233
- api.public.updateConnection,
234
- {
235
- connectionId,
236
- lastDataUpdate: new Date().toISOString(),
237
- },
238
- );
239
-
240
- return { synced: result.synced, errors: result.errors };
241
- },
242
- });
243
-
244
- // ─── Sync Engine ────────────────────────────────────────────────────────────
245
-
246
- /**
247
- * Fetch and ingest all Strava data types for a connected user.
248
- *
249
- * Called by syncStrava after obtaining a valid access token.
250
- */
251
- export const syncAllTypes = action({
252
- args: {
253
- accessToken: v.string(),
254
- connectionId: v.id("connections"),
255
- userId: v.string(),
256
- after: v.optional(v.number()),
257
- before: v.optional(v.number()),
258
- },
259
- handler: async (ctx, args) => {
260
- const { accessToken, connectionId, userId } = args;
261
- const client = createStravaClient(accessToken);
262
-
263
- const synced = { athletes: 0, activities: 0 };
264
- const errors: Array<{ type: string; id: string; error: string }> = [];
265
-
266
- // ── Athlete ──────────────────────────────────────────────────────────
267
- try {
268
- const { data: athlete, error } = await getLoggedInAthlete({ client });
269
- if (error || !athlete) throw new Error(error ? JSON.stringify(error) : "No athlete data");
270
- const data = transformAthlete(athlete);
271
- await ctx.runMutation(api.public.ingestAthlete, {
272
- connectionId,
273
- userId,
274
- ...data,
275
- } as never);
276
- synced.athletes++;
277
- } catch (err) {
278
- errors.push({
279
- type: "athlete",
280
- id: "fetch",
281
- error: err instanceof Error ? err.message : String(err),
282
- });
283
- }
284
-
285
- // ── Activities ───────────────────────────────────────────────────────
286
- try {
287
- const summaries = await listAllActivities(client, {
288
- after: args.after,
289
- before: args.before,
290
- });
291
- for (const summary of summaries) {
292
- if (summary.id == null) continue;
293
- try {
294
- const { data: detailed, error: detailError } = await getActivityById({ client, path: { id: summary.id } });
295
- if (detailError || !detailed) throw new Error(detailError ? JSON.stringify(detailError) : "No activity data");
296
-
297
- const { data: streams } = await getActivityStreams({
298
- client,
299
- path: { id: summary.id },
300
- query: {
301
- keys: ["time", "heartrate", "watts", "cadence", "latlng", "altitude", "velocity_smooth", "grade_smooth", "distance", "temp"],
302
- key_by_type: true,
303
- },
304
- });
305
-
306
- const data = transformActivity(detailed, { streams: streams ?? undefined });
307
- await ctx.runMutation(api.public.ingestActivity, {
308
- connectionId,
309
- userId,
310
- ...data,
311
- } as never);
312
- synced.activities++;
313
- } catch (err) {
314
- errors.push({
315
- type: "activity",
316
- id: String(summary.id),
317
- error: err instanceof Error ? err.message : String(err),
318
- });
319
- }
320
- }
321
- } catch (err) {
322
- errors.push({
323
- type: "activity",
324
- id: "fetch",
325
- error: err instanceof Error ? err.message : String(err),
326
- });
327
- }
328
-
329
- return { synced, errors };
330
- },
331
- });
332
-
333
- // ─── Disconnect ─────────────────────────────────────────────────────────────
334
-
335
- /**
336
- * Disconnect a user from Strava.
337
- *
338
- * Revokes the token at Strava (best-effort), deletes stored tokens,
339
- * and sets the connection to inactive.
340
- */
341
- export const disconnectStrava = action({
342
- args: {
343
- userId: v.string(),
344
- clientId: v.string(),
345
- clientSecret: v.string(),
346
- },
347
- returns: v.null(),
348
- handler: async (ctx, args) => {
349
- // 1. Look up connection
350
- const connection: Doc<"connections"> | null = await ctx.runQuery(
351
- internal.private.getConnectionByProvider,
352
- { userId: args.userId, provider: "STRAVA" },
353
- );
354
- if (!connection) {
355
- throw new Error(
356
- `No Strava connection found for user "${args.userId}".`,
357
- );
358
- }
359
-
360
- const connectionId = connection._id;
361
-
362
- // 2. Revoke token at Strava (best-effort, don't fail if it errors)
363
- const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(
364
- internal.strava.private.getTokens,
365
- { connectionId },
366
- );
367
- if (tokenDoc) {
368
- try {
369
- await fetch("https://www.strava.com/oauth/deauthorize", {
370
- method: "POST",
371
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
372
- body: `access_token=${tokenDoc.accessToken}`,
373
- });
374
- } catch {
375
- // Deauthorization is best-effort — clean up locally regardless
376
- }
377
-
378
- // 3. Delete stored tokens
379
- const _deleted: null = await ctx.runMutation(
380
- internal.strava.private.deleteTokens,
381
- { connectionId },
382
- );
383
- }
384
-
385
- // 4. Set connection inactive
386
- const _disconnected: null = await ctx.runMutation(api.public.disconnect, {
387
- userId: args.userId,
388
- provider: "STRAVA",
389
- });
390
-
391
- return null;
392
- },
393
- });
1
+ // ─── Strava Component Actions ────────────────────────────────────────────────
2
+ // Public actions that handle the full Strava OAuth + sync lifecycle.
3
+ // The host app calls these through the Soma class, which threads the
4
+ // credentials automatically from env vars or constructor config.
5
+
6
+ import { v } from "convex/values";
7
+ import { action } from "../_generated/server";
8
+ import { api, internal } from "../_generated/api";
9
+ import type { Doc, Id } from "../_generated/dataModel";
10
+ import { createStravaClient } from "./client.js";
11
+ import { listAllActivities } from "./utils.js";
12
+ import {
13
+ getLoggedInAthlete,
14
+ getActivityById,
15
+ getActivityStreams,
16
+ } from "./types/stravaApi/sdk.gen.js";
17
+ import { generateState } from "../utils.js";
18
+ import {
19
+ buildAuthUrl,
20
+ exchangeCode,
21
+ refreshToken as refreshStravaToken,
22
+ } from "./auth.js";
23
+ import { transformActivity } from "./transform/activity.js";
24
+ import { transformAthlete } from "./transform/athlete.js";
25
+
26
+ // ─── Public Actions ──────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Generate a Strava OAuth authorization URL.
30
+ *
31
+ * The state parameter is stored in the component's `pendingOAuth` table
32
+ * so that `completeStravaOAuth` can look it up automatically when the
33
+ * callback fires via `registerRoutes`.
34
+ */
35
+ export const getStravaAuthUrl = action({
36
+ args: {
37
+ clientId: v.string(),
38
+ redirectUri: v.string(),
39
+ scope: v.optional(v.string()),
40
+ userId: v.string(),
41
+ },
42
+ handler: async (ctx, args) => {
43
+ const state = generateState();
44
+
45
+ const authUrl = buildAuthUrl({
46
+ clientId: args.clientId,
47
+ redirectUri: args.redirectUri,
48
+ scope: args.scope,
49
+ state,
50
+ });
51
+
52
+ await ctx.runMutation(internal.strava.private.storePendingOAuth, {
53
+ provider: "STRAVA",
54
+ state,
55
+ userId: args.userId,
56
+ });
57
+
58
+ return { authUrl, state };
59
+ },
60
+ });
61
+
62
+ /**
63
+ * Complete a Strava OAuth flow using stored pending state.
64
+ *
65
+ * Called internally by `registerRoutes` — the callback handler calls
66
+ * this with the `code` and `state` from the redirect. The action looks
67
+ * up the pending state (userId) stored during `getStravaAuthUrl`,
68
+ * exchanges for tokens, creates the connection, stores tokens, and
69
+ * cleans up the pending entry.
70
+ *
71
+ * The host app is responsible for calling `syncStrava` afterwards.
72
+ */
73
+ export const completeStravaOAuth = action({
74
+ args: {
75
+ code: v.string(),
76
+ state: v.string(),
77
+ clientId: v.string(),
78
+ clientSecret: v.string(),
79
+ },
80
+ returns: v.object({
81
+ connectionId: v.string(),
82
+ userId: v.string(),
83
+ }),
84
+ handler: async (ctx, args): Promise<{
85
+ connectionId: Id<"connections">;
86
+ userId: string;
87
+ }> => {
88
+ // 1. Look up pending state
89
+ const pending: Doc<"pendingOAuth"> | null = await ctx.runQuery(
90
+ internal.strava.private.getPendingOAuth,
91
+ { state: args.state },
92
+ );
93
+ if (!pending) {
94
+ throw new Error(
95
+ "No pending Strava OAuth state found for this state parameter. " +
96
+ "The authorization may have expired or was already used.",
97
+ );
98
+ }
99
+
100
+ // 2. Exchange authorization code for tokens
101
+ const tokens = await exchangeCode({
102
+ clientId: args.clientId,
103
+ clientSecret: args.clientSecret,
104
+ code: args.code,
105
+ });
106
+
107
+ // 3. Clean up pending entry
108
+ await ctx.runMutation(
109
+ internal.strava.private.deletePendingOAuth,
110
+ { state: args.state },
111
+ );
112
+
113
+ // 4. Create/reactivate the Soma connection
114
+ const connectionId: Id<"connections"> = await ctx.runMutation(
115
+ api.public.connect,
116
+ {
117
+ userId: pending.userId,
118
+ provider: "STRAVA",
119
+ },
120
+ );
121
+
122
+ // 5. Store OAuth tokens
123
+ await ctx.runMutation(
124
+ internal.strava.private.storeTokens,
125
+ {
126
+ connectionId,
127
+ accessToken: tokens.access_token,
128
+ refreshToken: tokens.refresh_token,
129
+ expiresAt: tokens.expires_at,
130
+ },
131
+ );
132
+
133
+ return {
134
+ connectionId,
135
+ userId: pending.userId,
136
+ };
137
+ },
138
+ });
139
+
140
+ /**
141
+ * Incremental Strava sync for an already-connected user.
142
+ *
143
+ * Looks up the stored tokens, auto-refreshes if expired, then syncs
144
+ * the athlete profile and activities.
145
+ *
146
+ * Returns `{ synced, errors }`.
147
+ */
148
+ export const syncStrava = action({
149
+ args: {
150
+ userId: v.string(),
151
+ clientId: v.string(),
152
+ clientSecret: v.string(),
153
+ after: v.optional(v.number()),
154
+ },
155
+ returns: v.object({
156
+ data: v.object({ synced: v.object({ athletes: v.number(), activities: v.number() }) }),
157
+ errors: v.array(
158
+ v.object({ type: v.string(), id: v.string(), message: v.string() }),
159
+ ),
160
+ }),
161
+ handler: async (ctx, args): Promise<{
162
+ data: { synced: { athletes: number; activities: number } };
163
+ errors: Array<{ type: string; id: string; message: string }>;
164
+ }> => {
165
+ // 1. Look up connection
166
+ const connection: Doc<"connections"> | null = await ctx.runQuery(
167
+ internal.private.getConnectionByProvider,
168
+ { userId: args.userId, provider: "STRAVA" },
169
+ );
170
+ if (!connection) {
171
+ throw new Error(
172
+ `No Strava connection found for user "${args.userId}". ` +
173
+ "Connect to Strava first via getStravaAuthUrl.",
174
+ );
175
+ }
176
+ if (!connection.active) {
177
+ throw new Error(
178
+ `Strava connection for user "${args.userId}" is inactive. Reconnect first.`,
179
+ );
180
+ }
181
+
182
+ const connectionId = connection._id;
183
+
184
+ // 2. Get stored tokens
185
+ const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(
186
+ internal.strava.private.getTokens,
187
+ { connectionId },
188
+ );
189
+ if (!tokenDoc) {
190
+ throw new Error(
191
+ `No tokens found for Strava connection. ` +
192
+ "The connection may have been created before token storage was available.",
193
+ );
194
+ }
195
+
196
+ // 3. Auto-refresh if token is expired or expiring within 5 minutes
197
+ let accessToken = tokenDoc.accessToken;
198
+ const now = Math.floor(Date.now() / 1000);
199
+ if (!tokenDoc.refreshToken || !tokenDoc.expiresAt) {
200
+ throw new Error(
201
+ "Strava tokens are missing refreshToken or expiresAt. " +
202
+ "This connection may have been created with an incompatible version.",
203
+ );
204
+ }
205
+ if (tokenDoc.expiresAt < now + 300) {
206
+ const refreshed = await refreshStravaToken({
207
+ clientId: args.clientId,
208
+ clientSecret: args.clientSecret,
209
+ refreshToken: tokenDoc.refreshToken,
210
+ });
211
+ accessToken = refreshed.access_token;
212
+ await ctx.runMutation(
213
+ internal.strava.private.storeTokens,
214
+ {
215
+ connectionId,
216
+ accessToken: refreshed.access_token,
217
+ refreshToken: refreshed.refresh_token,
218
+ expiresAt: refreshed.expires_at,
219
+ },
220
+ );
221
+ }
222
+
223
+ // 4. Sync all data types
224
+ const result = await ctx.runAction(api.strava.public.syncAllTypes, {
225
+ accessToken,
226
+ connectionId,
227
+ userId: args.userId,
228
+ after: args.after,
229
+ });
230
+
231
+ // 5. Update lastDataUpdate timestamp
232
+ await ctx.runMutation(
233
+ api.public.updateConnection,
234
+ {
235
+ connectionId,
236
+ lastDataUpdate: new Date().toISOString(),
237
+ },
238
+ );
239
+
240
+ return { data: { synced: result.data.synced }, errors: result.errors };
241
+ },
242
+ });
243
+
244
+ // ─── Sync Engine ────────────────────────────────────────────────────────────
245
+
246
+ /**
247
+ * Fetch and ingest all Strava data types for a connected user.
248
+ *
249
+ * Called by syncStrava after obtaining a valid access token.
250
+ */
251
+ export const syncAllTypes = action({
252
+ args: {
253
+ accessToken: v.string(),
254
+ connectionId: v.id("connections"),
255
+ userId: v.string(),
256
+ after: v.optional(v.number()),
257
+ before: v.optional(v.number()),
258
+ },
259
+ handler: async (ctx, args) => {
260
+ const { accessToken, connectionId, userId } = args;
261
+ const client = createStravaClient(accessToken);
262
+
263
+ const synced = { athletes: 0, activities: 0 };
264
+ const errors: Array<{ type: string; id: string; message: string }> = [];
265
+
266
+ // ── Athlete ──────────────────────────────────────────────────────────
267
+ try {
268
+ const { data: athlete, error } = await getLoggedInAthlete({ client });
269
+ if (error || !athlete) throw new Error(error ? JSON.stringify(error) : "No athlete data");
270
+ const data = transformAthlete(athlete);
271
+ await ctx.runMutation(api.public.ingestAthlete, {
272
+ connectionId,
273
+ userId,
274
+ ...data,
275
+ } as never);
276
+ synced.athletes++;
277
+ } catch (err) {
278
+ errors.push({
279
+ type: "athlete",
280
+ id: "fetch",
281
+ message: err instanceof Error ? err.message : String(err),
282
+ });
283
+ }
284
+
285
+ // ── Activities ───────────────────────────────────────────────────────
286
+ try {
287
+ const summaries = await listAllActivities(client, {
288
+ after: args.after,
289
+ before: args.before,
290
+ });
291
+ for (const summary of summaries) {
292
+ if (summary.id == null) continue;
293
+ try {
294
+ const { data: detailed, error: detailError } = await getActivityById({ client, path: { id: summary.id } });
295
+ if (detailError || !detailed) throw new Error(detailError ? JSON.stringify(detailError) : "No activity data");
296
+
297
+ const { data: streams } = await getActivityStreams({
298
+ client,
299
+ path: { id: summary.id },
300
+ query: {
301
+ keys: ["time", "heartrate", "watts", "cadence", "latlng", "altitude", "velocity_smooth", "grade_smooth", "distance", "temp"],
302
+ key_by_type: true,
303
+ },
304
+ });
305
+
306
+ const data = transformActivity(detailed, { streams: streams ?? undefined });
307
+ await ctx.runMutation(api.public.ingestActivity, {
308
+ connectionId,
309
+ userId,
310
+ ...data,
311
+ } as never);
312
+ synced.activities++;
313
+ } catch (err) {
314
+ errors.push({
315
+ type: "activity",
316
+ id: String(summary.id),
317
+ message: err instanceof Error ? err.message : String(err),
318
+ });
319
+ }
320
+ }
321
+ } catch (err) {
322
+ errors.push({
323
+ type: "activity",
324
+ id: "fetch",
325
+ message: err instanceof Error ? err.message : String(err),
326
+ });
327
+ }
328
+
329
+ return { data: { synced }, errors };
330
+ },
331
+ });
332
+
333
+ // ─── Disconnect ─────────────────────────────────────────────────────────────
334
+
335
+ /**
336
+ * Disconnect a user from Strava.
337
+ *
338
+ * Revokes the token at Strava (best-effort), deletes stored tokens,
339
+ * and sets the connection to inactive.
340
+ */
341
+ export const disconnectStrava = action({
342
+ args: {
343
+ userId: v.string(),
344
+ clientId: v.string(),
345
+ clientSecret: v.string(),
346
+ },
347
+ returns: v.null(),
348
+ handler: async (ctx, args) => {
349
+ // 1. Look up connection
350
+ const connection: Doc<"connections"> | null = await ctx.runQuery(
351
+ internal.private.getConnectionByProvider,
352
+ { userId: args.userId, provider: "STRAVA" },
353
+ );
354
+ if (!connection) {
355
+ throw new Error(
356
+ `No Strava connection found for user "${args.userId}".`,
357
+ );
358
+ }
359
+
360
+ const connectionId = connection._id;
361
+
362
+ // 2. Revoke token at Strava (best-effort, don't fail if it errors)
363
+ const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(
364
+ internal.strava.private.getTokens,
365
+ { connectionId },
366
+ );
367
+ if (tokenDoc) {
368
+ try {
369
+ await fetch("https://www.strava.com/oauth/deauthorize", {
370
+ method: "POST",
371
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
372
+ body: `access_token=${tokenDoc.accessToken}`,
373
+ });
374
+ } catch {
375
+ // Deauthorization is best-effort — clean up locally regardless
376
+ }
377
+
378
+ // 3. Delete stored tokens
379
+ const _deleted: null = await ctx.runMutation(
380
+ internal.strava.private.deleteTokens,
381
+ { connectionId },
382
+ );
383
+ }
384
+
385
+ // 4. Set connection inactive
386
+ const _disconnected: null = await ctx.runMutation(api.public.disconnect, {
387
+ userId: args.userId,
388
+ provider: "STRAVA",
389
+ });
390
+
391
+ return null;
392
+ },
393
+ });