@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.
Files changed (71) hide show
  1. package/README.md +260 -19
  2. package/dist/client/index.d.ts +158 -4
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +165 -3
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +2 -0
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +37 -0
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/public.d.ts +3 -3
  12. package/dist/component/schema.d.ts +18 -5
  13. package/dist/component/schema.d.ts.map +1 -1
  14. package/dist/component/schema.js +10 -0
  15. package/dist/component/schema.js.map +1 -1
  16. package/dist/component/strava.d.ts +88 -0
  17. package/dist/component/strava.d.ts.map +1 -0
  18. package/dist/component/strava.js +318 -0
  19. package/dist/component/strava.js.map +1 -0
  20. package/dist/component/validators/activity.d.ts +4 -4
  21. package/dist/component/validators/samples.d.ts +2 -2
  22. package/dist/strava/activity.d.ts +121 -0
  23. package/dist/strava/activity.d.ts.map +1 -0
  24. package/dist/strava/activity.js +201 -0
  25. package/dist/strava/activity.js.map +1 -0
  26. package/dist/strava/athlete.d.ts +34 -0
  27. package/dist/strava/athlete.d.ts.map +1 -0
  28. package/dist/strava/athlete.js +39 -0
  29. package/dist/strava/athlete.js.map +1 -0
  30. package/dist/strava/auth.d.ts +103 -0
  31. package/dist/strava/auth.d.ts.map +1 -0
  32. package/dist/strava/auth.js +111 -0
  33. package/dist/strava/auth.js.map +1 -0
  34. package/dist/strava/client.d.ts +93 -0
  35. package/dist/strava/client.d.ts.map +1 -0
  36. package/dist/strava/client.js +158 -0
  37. package/dist/strava/client.js.map +1 -0
  38. package/dist/strava/index.d.ts +13 -0
  39. package/dist/strava/index.d.ts.map +1 -0
  40. package/dist/strava/index.js +17 -0
  41. package/dist/strava/index.js.map +1 -0
  42. package/dist/strava/maps/sport-type.d.ts +7 -0
  43. package/dist/strava/maps/sport-type.d.ts.map +1 -0
  44. package/dist/strava/maps/sport-type.js +84 -0
  45. package/dist/strava/maps/sport-type.js.map +1 -0
  46. package/dist/strava/sync.d.ts +104 -0
  47. package/dist/strava/sync.d.ts.map +1 -0
  48. package/dist/strava/sync.js +87 -0
  49. package/dist/strava/sync.js.map +1 -0
  50. package/dist/strava/types.d.ts +266 -0
  51. package/dist/strava/types.d.ts.map +1 -0
  52. package/dist/strava/types.js +8 -0
  53. package/dist/strava/types.js.map +1 -0
  54. package/package.json +5 -1
  55. package/src/client/index.ts +212 -4
  56. package/src/component/_generated/api.ts +2 -0
  57. package/src/component/_generated/component.ts +49 -0
  58. package/src/component/schema.ts +11 -0
  59. package/src/component/strava.ts +383 -0
  60. package/src/strava/activity.test.ts +415 -0
  61. package/src/strava/activity.ts +276 -0
  62. package/src/strava/athlete.test.ts +139 -0
  63. package/src/strava/athlete.ts +47 -0
  64. package/src/strava/auth.test.ts +78 -0
  65. package/src/strava/auth.ts +185 -0
  66. package/src/strava/client.ts +212 -0
  67. package/src/strava/index.ts +54 -0
  68. package/src/strava/maps/sport-type.test.ts +69 -0
  69. package/src/strava/maps/sport-type.ts +99 -0
  70. package/src/strava/sync.ts +168 -0
  71. 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
+ });