@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.
- package/dist/client/garmin.d.ts +291 -0
- package/dist/client/garmin.d.ts.map +1 -0
- package/dist/client/garmin.js +493 -0
- package/dist/client/garmin.js.map +1 -0
- package/dist/client/index.d.ts +29 -394
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +30 -520
- package/dist/client/index.js.map +1 -1
- package/dist/client/strava.d.ts +97 -0
- package/dist/client/strava.d.ts.map +1 -0
- package/dist/client/strava.js +160 -0
- package/dist/client/strava.js.map +1 -0
- package/dist/client/types.d.ts +238 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +24 -12
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin/private.d.ts +53 -68
- package/dist/component/garmin/private.d.ts.map +1 -1
- package/dist/component/garmin/private.js +87 -85
- package/dist/component/garmin/private.js.map +1 -1
- package/dist/component/garmin/public.d.ts +97 -53
- package/dist/component/garmin/public.d.ts.map +1 -1
- package/dist/component/garmin/public.js +75 -148
- package/dist/component/garmin/public.js.map +1 -1
- package/dist/component/garmin/webhooks.d.ts +22 -20
- package/dist/component/garmin/webhooks.d.ts.map +1 -1
- package/dist/component/garmin/webhooks.js +115 -76
- package/dist/component/garmin/webhooks.js.map +1 -1
- package/dist/component/public.d.ts +15 -15
- package/dist/component/schema.d.ts +25 -25
- package/dist/component/strava/public.d.ts +12 -8
- package/dist/component/strava/public.d.ts.map +1 -1
- package/dist/component/strava/public.js +7 -7
- package/dist/component/strava/public.js.map +1 -1
- package/dist/component/validators/activity.d.ts +4 -4
- package/dist/component/validators/body.d.ts +4 -4
- package/dist/component/validators/daily.d.ts +4 -4
- package/dist/component/validators/nutrition.d.ts +3 -3
- package/dist/component/validators/samples.d.ts +4 -4
- package/dist/component/validators/shared.d.ts +13 -4
- package/dist/component/validators/shared.d.ts.map +1 -1
- package/dist/component/validators/shared.js +7 -0
- package/dist/component/validators/shared.js.map +1 -1
- package/dist/component/validators/sleep.d.ts +5 -5
- package/dist/validators.d.ts +41 -40
- package/dist/validators.d.ts.map +1 -1
- package/dist/validators.js +1 -0
- package/dist/validators.js.map +1 -1
- package/package.json +1 -1
- package/src/client/garmin.ts +692 -0
- package/src/client/index.ts +68 -933
- package/src/client/strava.ts +199 -0
- package/src/client/types.ts +285 -0
- package/src/component/_generated/component.ts +19 -32
- package/src/component/garmin/private.ts +1872 -1870
- package/src/component/garmin/public.ts +1073 -1184
- package/src/component/garmin/webhooks.ts +898 -857
- package/src/component/strava/public.ts +393 -393
- package/src/component/validators/shared.ts +9 -0
- 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(),
|
|
159
|
-
),
|
|
160
|
-
}),
|
|
161
|
-
handler: async (ctx, args): Promise<{
|
|
162
|
-
synced: { athletes: number; activities: number };
|
|
163
|
-
errors: Array<{ type: string; id: 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;
|
|
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
|
-
|
|
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
|
-
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
} catch (err) {
|
|
322
|
-
errors.push({
|
|
323
|
-
type: "activity",
|
|
324
|
-
id: "fetch",
|
|
325
|
-
|
|
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
|
+
});
|