@nativesquare/soma 0.1.2 → 0.2.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/README.md +260 -19
- package/dist/client/index.d.ts +158 -4
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +165 -3
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -0
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +37 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/public.d.ts +3 -3
- package/dist/component/schema.d.ts +18 -5
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +10 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/strava.d.ts +88 -0
- package/dist/component/strava.d.ts.map +1 -0
- package/dist/component/strava.js +318 -0
- package/dist/component/strava.js.map +1 -0
- package/dist/component/validators/activity.d.ts +4 -4
- package/dist/component/validators/samples.d.ts +2 -2
- package/dist/strava/activity.d.ts +121 -0
- package/dist/strava/activity.d.ts.map +1 -0
- package/dist/strava/activity.js +201 -0
- package/dist/strava/activity.js.map +1 -0
- package/dist/strava/athlete.d.ts +34 -0
- package/dist/strava/athlete.d.ts.map +1 -0
- package/dist/strava/athlete.js +39 -0
- package/dist/strava/athlete.js.map +1 -0
- package/dist/strava/auth.d.ts +103 -0
- package/dist/strava/auth.d.ts.map +1 -0
- package/dist/strava/auth.js +111 -0
- package/dist/strava/auth.js.map +1 -0
- package/dist/strava/client.d.ts +93 -0
- package/dist/strava/client.d.ts.map +1 -0
- package/dist/strava/client.js +158 -0
- package/dist/strava/client.js.map +1 -0
- package/dist/strava/index.d.ts +13 -0
- package/dist/strava/index.d.ts.map +1 -0
- package/dist/strava/index.js +17 -0
- package/dist/strava/index.js.map +1 -0
- package/dist/strava/maps/sport-type.d.ts +7 -0
- package/dist/strava/maps/sport-type.d.ts.map +1 -0
- package/dist/strava/maps/sport-type.js +84 -0
- package/dist/strava/maps/sport-type.js.map +1 -0
- package/dist/strava/sync.d.ts +104 -0
- package/dist/strava/sync.d.ts.map +1 -0
- package/dist/strava/sync.js +87 -0
- package/dist/strava/sync.js.map +1 -0
- package/dist/strava/types.d.ts +266 -0
- package/dist/strava/types.d.ts.map +1 -0
- package/dist/strava/types.js +8 -0
- package/dist/strava/types.js.map +1 -0
- package/package.json +5 -1
- package/src/client/index.ts +212 -4
- package/src/component/_generated/api.ts +2 -0
- package/src/component/_generated/component.ts +49 -0
- package/src/component/schema.ts +11 -0
- package/src/component/strava.ts +383 -0
- package/src/strava/activity.test.ts +415 -0
- package/src/strava/activity.ts +276 -0
- package/src/strava/athlete.test.ts +139 -0
- package/src/strava/athlete.ts +47 -0
- package/src/strava/auth.test.ts +78 -0
- package/src/strava/auth.ts +185 -0
- package/src/strava/client.ts +212 -0
- package/src/strava/index.ts +54 -0
- package/src/strava/maps/sport-type.test.ts +69 -0
- package/src/strava/maps/sport-type.ts +99 -0
- package/src/strava/sync.ts +168 -0
- package/src/strava/types.ts +361 -0
|
@@ -0,0 +1,383 @@
|
|
|
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
|
+
// Internal mutations manage the providerTokens table (token CRUD).
|
|
7
|
+
|
|
8
|
+
import { v } from "convex/values";
|
|
9
|
+
import { anyApi } from "convex/server";
|
|
10
|
+
import {
|
|
11
|
+
action,
|
|
12
|
+
internalMutation,
|
|
13
|
+
internalQuery,
|
|
14
|
+
} from "./_generated/server.js";
|
|
15
|
+
import { StravaClient } from "../strava/client.js";
|
|
16
|
+
import {
|
|
17
|
+
exchangeCode,
|
|
18
|
+
refreshToken as refreshStravaToken,
|
|
19
|
+
} from "../strava/auth.js";
|
|
20
|
+
import { transformActivity } from "../strava/activity.js";
|
|
21
|
+
import { transformAthlete } from "../strava/athlete.js";
|
|
22
|
+
|
|
23
|
+
// Use anyApi to avoid circular type references between this file and _generated/api.ts.
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
+
const publicApi: any = anyApi;
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
const internalApi: any = anyApi;
|
|
28
|
+
|
|
29
|
+
// ─── Internal Token CRUD ─────────────────────────────────────────────────────
|
|
30
|
+
// These are only callable from within the component (actions in this file).
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Store or update OAuth tokens for a connection.
|
|
34
|
+
* Upserts by connectionId — one token record per connection.
|
|
35
|
+
*/
|
|
36
|
+
export const storeTokens = internalMutation({
|
|
37
|
+
args: {
|
|
38
|
+
connectionId: v.id("connections"),
|
|
39
|
+
accessToken: v.string(),
|
|
40
|
+
refreshToken: v.string(),
|
|
41
|
+
expiresAt: v.number(),
|
|
42
|
+
},
|
|
43
|
+
handler: async (ctx, args) => {
|
|
44
|
+
const existing = await ctx.db
|
|
45
|
+
.query("providerTokens")
|
|
46
|
+
.withIndex("by_connectionId", (q) =>
|
|
47
|
+
q.eq("connectionId", args.connectionId),
|
|
48
|
+
)
|
|
49
|
+
.first();
|
|
50
|
+
|
|
51
|
+
if (existing) {
|
|
52
|
+
await ctx.db.patch(existing._id, {
|
|
53
|
+
accessToken: args.accessToken,
|
|
54
|
+
refreshToken: args.refreshToken,
|
|
55
|
+
expiresAt: args.expiresAt,
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await ctx.db.insert("providerTokens", args);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get stored tokens for a connection.
|
|
66
|
+
*/
|
|
67
|
+
export const getTokens = internalQuery({
|
|
68
|
+
args: { connectionId: v.id("connections") },
|
|
69
|
+
handler: async (ctx, args) => {
|
|
70
|
+
return await ctx.db
|
|
71
|
+
.query("providerTokens")
|
|
72
|
+
.withIndex("by_connectionId", (q) =>
|
|
73
|
+
q.eq("connectionId", args.connectionId),
|
|
74
|
+
)
|
|
75
|
+
.first();
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Delete stored tokens for a connection.
|
|
81
|
+
*/
|
|
82
|
+
export const deleteTokens = internalMutation({
|
|
83
|
+
args: { connectionId: v.id("connections") },
|
|
84
|
+
handler: async (ctx, args) => {
|
|
85
|
+
const existing = await ctx.db
|
|
86
|
+
.query("providerTokens")
|
|
87
|
+
.withIndex("by_connectionId", (q) =>
|
|
88
|
+
q.eq("connectionId", args.connectionId),
|
|
89
|
+
)
|
|
90
|
+
.first();
|
|
91
|
+
|
|
92
|
+
if (existing) {
|
|
93
|
+
await ctx.db.delete(existing._id);
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ─── Public Actions ──────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Full Strava OAuth callback handler.
|
|
102
|
+
*
|
|
103
|
+
* Exchanges the authorization code for tokens, creates/reactivates the
|
|
104
|
+
* Soma connection, stores tokens securely, syncs the athlete profile,
|
|
105
|
+
* and syncs all activities.
|
|
106
|
+
*
|
|
107
|
+
* Returns `{ connectionId, synced, errors }`.
|
|
108
|
+
*/
|
|
109
|
+
export const connectStrava = action({
|
|
110
|
+
args: {
|
|
111
|
+
userId: v.string(),
|
|
112
|
+
code: v.string(),
|
|
113
|
+
clientId: v.string(),
|
|
114
|
+
clientSecret: v.string(),
|
|
115
|
+
baseUrl: v.optional(v.string()),
|
|
116
|
+
includeStreams: v.optional(v.boolean()),
|
|
117
|
+
},
|
|
118
|
+
returns: v.object({
|
|
119
|
+
connectionId: v.string(),
|
|
120
|
+
synced: v.number(),
|
|
121
|
+
errors: v.array(
|
|
122
|
+
v.object({ activityId: v.number(), error: v.string() }),
|
|
123
|
+
),
|
|
124
|
+
}),
|
|
125
|
+
handler: async (ctx, args) => {
|
|
126
|
+
// 1. Exchange authorization code for tokens
|
|
127
|
+
const tokens = await exchangeCode({
|
|
128
|
+
clientId: args.clientId,
|
|
129
|
+
clientSecret: args.clientSecret,
|
|
130
|
+
code: args.code,
|
|
131
|
+
baseUrl: args.baseUrl,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// 2. Create/reactivate the Soma connection
|
|
135
|
+
const connectionId = await ctx.runMutation(publicApi.public.connect, {
|
|
136
|
+
userId: args.userId,
|
|
137
|
+
provider: "STRAVA",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// 3. Store OAuth tokens in providerTokens table
|
|
141
|
+
await ctx.runMutation(internalApi.strava.storeTokens, {
|
|
142
|
+
connectionId,
|
|
143
|
+
accessToken: tokens.access_token,
|
|
144
|
+
refreshToken: tokens.refresh_token,
|
|
145
|
+
expiresAt: tokens.expires_at,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// 4. Sync athlete profile
|
|
149
|
+
const client = new StravaClient({
|
|
150
|
+
accessToken: tokens.access_token,
|
|
151
|
+
baseUrl: args.baseUrl,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const athlete = await client.getAthlete();
|
|
155
|
+
const athleteData = transformAthlete(athlete);
|
|
156
|
+
await ctx.runMutation(publicApi.public.ingestAthlete, {
|
|
157
|
+
connectionId,
|
|
158
|
+
userId: args.userId,
|
|
159
|
+
...athleteData,
|
|
160
|
+
} as never);
|
|
161
|
+
|
|
162
|
+
// 5. Sync all activities
|
|
163
|
+
const summaries = await client.listAllActivities();
|
|
164
|
+
let synced = 0;
|
|
165
|
+
const errors: Array<{ activityId: number; error: string }> = [];
|
|
166
|
+
|
|
167
|
+
for (const summary of summaries) {
|
|
168
|
+
try {
|
|
169
|
+
const detailed = await client.getActivity(summary.id);
|
|
170
|
+
const streams = args.includeStreams
|
|
171
|
+
? await client.getActivityStreams(summary.id)
|
|
172
|
+
: undefined;
|
|
173
|
+
const data = transformActivity(detailed, { streams });
|
|
174
|
+
await ctx.runMutation(publicApi.public.ingestActivity, {
|
|
175
|
+
connectionId,
|
|
176
|
+
userId: args.userId,
|
|
177
|
+
...data,
|
|
178
|
+
} as never);
|
|
179
|
+
synced++;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
errors.push({
|
|
182
|
+
activityId: summary.id,
|
|
183
|
+
error: err instanceof Error ? err.message : String(err),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 6. Update lastDataUpdate timestamp
|
|
189
|
+
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
190
|
+
connectionId,
|
|
191
|
+
lastDataUpdate: new Date().toISOString(),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return { connectionId, synced, errors };
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Incremental Strava sync for an already-connected user.
|
|
200
|
+
*
|
|
201
|
+
* Looks up the stored tokens, auto-refreshes if expired, then syncs
|
|
202
|
+
* the athlete profile and activities.
|
|
203
|
+
*
|
|
204
|
+
* Returns `{ synced, errors }`.
|
|
205
|
+
*/
|
|
206
|
+
export const syncStrava = action({
|
|
207
|
+
args: {
|
|
208
|
+
userId: v.string(),
|
|
209
|
+
clientId: v.string(),
|
|
210
|
+
clientSecret: v.string(),
|
|
211
|
+
baseUrl: v.optional(v.string()),
|
|
212
|
+
includeStreams: v.optional(v.boolean()),
|
|
213
|
+
after: v.optional(v.number()),
|
|
214
|
+
},
|
|
215
|
+
returns: v.object({
|
|
216
|
+
synced: v.number(),
|
|
217
|
+
errors: v.array(
|
|
218
|
+
v.object({ activityId: v.number(), error: v.string() }),
|
|
219
|
+
),
|
|
220
|
+
}),
|
|
221
|
+
handler: async (ctx, args) => {
|
|
222
|
+
// 1. Look up connection
|
|
223
|
+
const connection = await ctx.runQuery(
|
|
224
|
+
internalApi.private.getConnectionByProvider,
|
|
225
|
+
{ userId: args.userId, provider: "STRAVA" },
|
|
226
|
+
);
|
|
227
|
+
if (!connection) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`No Strava connection found for user "${args.userId}". ` +
|
|
230
|
+
"Call connectStrava first.",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (!connection.active) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Strava connection for user "${args.userId}" is inactive. Reconnect first.`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const connectionId = connection._id;
|
|
240
|
+
|
|
241
|
+
// 2. Get stored tokens
|
|
242
|
+
const tokenDoc = await ctx.runQuery(internalApi.strava.getTokens, {
|
|
243
|
+
connectionId,
|
|
244
|
+
});
|
|
245
|
+
if (!tokenDoc) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`No tokens found for Strava connection. ` +
|
|
248
|
+
"The connection may have been created before token storage was available.",
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 3. Auto-refresh if token is expired or expiring within 5 minutes
|
|
253
|
+
let accessToken = tokenDoc.accessToken;
|
|
254
|
+
const now = Math.floor(Date.now() / 1000);
|
|
255
|
+
if (tokenDoc.expiresAt < now + 300) {
|
|
256
|
+
const refreshed = await refreshStravaToken({
|
|
257
|
+
clientId: args.clientId,
|
|
258
|
+
clientSecret: args.clientSecret,
|
|
259
|
+
refreshToken: tokenDoc.refreshToken,
|
|
260
|
+
baseUrl: args.baseUrl,
|
|
261
|
+
});
|
|
262
|
+
accessToken = refreshed.access_token;
|
|
263
|
+
await ctx.runMutation(internalApi.strava.storeTokens, {
|
|
264
|
+
connectionId,
|
|
265
|
+
accessToken: refreshed.access_token,
|
|
266
|
+
refreshToken: refreshed.refresh_token,
|
|
267
|
+
expiresAt: refreshed.expires_at,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 4. Create client and sync
|
|
272
|
+
const client = new StravaClient({
|
|
273
|
+
accessToken,
|
|
274
|
+
baseUrl: args.baseUrl,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// Sync athlete profile
|
|
278
|
+
const athlete = await client.getAthlete();
|
|
279
|
+
const athleteData = transformAthlete(athlete);
|
|
280
|
+
await ctx.runMutation(publicApi.public.ingestAthlete, {
|
|
281
|
+
connectionId,
|
|
282
|
+
userId: args.userId,
|
|
283
|
+
...athleteData,
|
|
284
|
+
} as never);
|
|
285
|
+
|
|
286
|
+
// Sync activities (optionally incremental via `after`)
|
|
287
|
+
const summaries = await client.listAllActivities({
|
|
288
|
+
after: args.after,
|
|
289
|
+
});
|
|
290
|
+
let synced = 0;
|
|
291
|
+
const errors: Array<{ activityId: number; error: string }> = [];
|
|
292
|
+
|
|
293
|
+
for (const summary of summaries) {
|
|
294
|
+
try {
|
|
295
|
+
const detailed = await client.getActivity(summary.id);
|
|
296
|
+
const streams = args.includeStreams
|
|
297
|
+
? await client.getActivityStreams(summary.id)
|
|
298
|
+
: undefined;
|
|
299
|
+
const data = transformActivity(detailed, { streams });
|
|
300
|
+
await ctx.runMutation(publicApi.public.ingestActivity, {
|
|
301
|
+
connectionId,
|
|
302
|
+
userId: args.userId,
|
|
303
|
+
...data,
|
|
304
|
+
} as never);
|
|
305
|
+
synced++;
|
|
306
|
+
} catch (err) {
|
|
307
|
+
errors.push({
|
|
308
|
+
activityId: summary.id,
|
|
309
|
+
error: err instanceof Error ? err.message : String(err),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 5. Update lastDataUpdate timestamp
|
|
315
|
+
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
316
|
+
connectionId,
|
|
317
|
+
lastDataUpdate: new Date().toISOString(),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return { synced, errors };
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Disconnect a user from Strava.
|
|
326
|
+
*
|
|
327
|
+
* Revokes the token at Strava (best-effort), deletes stored tokens,
|
|
328
|
+
* and sets the connection to inactive.
|
|
329
|
+
*/
|
|
330
|
+
export const disconnectStrava = action({
|
|
331
|
+
args: {
|
|
332
|
+
userId: v.string(),
|
|
333
|
+
clientId: v.string(),
|
|
334
|
+
clientSecret: v.string(),
|
|
335
|
+
baseUrl: v.optional(v.string()),
|
|
336
|
+
},
|
|
337
|
+
returns: v.null(),
|
|
338
|
+
handler: async (ctx, args) => {
|
|
339
|
+
// 1. Look up connection
|
|
340
|
+
const connection = await ctx.runQuery(
|
|
341
|
+
internalApi.private.getConnectionByProvider,
|
|
342
|
+
{ userId: args.userId, provider: "STRAVA" },
|
|
343
|
+
);
|
|
344
|
+
if (!connection) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`No Strava connection found for user "${args.userId}".`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const connectionId = connection._id;
|
|
351
|
+
|
|
352
|
+
// 2. Revoke token at Strava (best-effort, don't fail if it errors)
|
|
353
|
+
const tokenDoc = await ctx.runQuery(internalApi.strava.getTokens, {
|
|
354
|
+
connectionId,
|
|
355
|
+
});
|
|
356
|
+
if (tokenDoc) {
|
|
357
|
+
try {
|
|
358
|
+
const base = (args.baseUrl ?? "https://www.strava.com").replace(
|
|
359
|
+
/\/+$/,
|
|
360
|
+
"",
|
|
361
|
+
);
|
|
362
|
+
await fetch(`${base}/oauth/deauthorize`, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
365
|
+
body: `access_token=${tokenDoc.accessToken}`,
|
|
366
|
+
});
|
|
367
|
+
} catch {
|
|
368
|
+
// Deauthorization is best-effort — clean up locally regardless
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 3. Delete stored tokens
|
|
372
|
+
await ctx.runMutation(internalApi.strava.deleteTokens, { connectionId });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 4. Set connection inactive
|
|
376
|
+
await ctx.runMutation(publicApi.public.disconnect, {
|
|
377
|
+
userId: args.userId,
|
|
378
|
+
provider: "STRAVA",
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
return null;
|
|
382
|
+
},
|
|
383
|
+
});
|