@nativesquare/soma 0.13.0 → 0.14.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 (96) hide show
  1. package/dist/client/garmin.d.ts +4 -1
  2. package/dist/client/garmin.d.ts.map +1 -1
  3. package/dist/client/garmin.js +4 -1
  4. package/dist/client/garmin.js.map +1 -1
  5. package/dist/client/index.d.ts +2 -2
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/client/strava.d.ts +48 -34
  10. package/dist/client/strava.d.ts.map +1 -1
  11. package/dist/client/strava.js +141 -23
  12. package/dist/client/strava.js.map +1 -1
  13. package/dist/client/types.d.ts +108 -0
  14. package/dist/client/types.d.ts.map +1 -1
  15. package/dist/component/_generated/api.d.ts +2 -2
  16. package/dist/component/_generated/api.d.ts.map +1 -1
  17. package/dist/component/_generated/component.d.ts +19 -17
  18. package/dist/component/_generated/component.d.ts.map +1 -1
  19. package/dist/component/garmin/auth.d.ts +2 -1
  20. package/dist/component/garmin/auth.d.ts.map +1 -1
  21. package/dist/component/garmin/auth.js +6 -1
  22. package/dist/component/garmin/auth.js.map +1 -1
  23. package/dist/component/garmin/private.d.ts +17 -75
  24. package/dist/component/garmin/private.d.ts.map +1 -1
  25. package/dist/component/garmin/private.js +4 -167
  26. package/dist/component/garmin/private.js.map +1 -1
  27. package/dist/component/garmin/public.d.ts +18 -33
  28. package/dist/component/garmin/public.d.ts.map +1 -1
  29. package/dist/component/garmin/public.js +23 -22
  30. package/dist/component/garmin/public.js.map +1 -1
  31. package/dist/component/garmin/webhooks.d.ts +3 -6
  32. package/dist/component/garmin/webhooks.d.ts.map +1 -1
  33. package/dist/component/garmin/webhooks.js +17 -28
  34. package/dist/component/garmin/webhooks.js.map +1 -1
  35. package/dist/component/private.d.ts +59 -0
  36. package/dist/component/private.d.ts.map +1 -1
  37. package/dist/component/private.js +182 -1
  38. package/dist/component/private.js.map +1 -1
  39. package/dist/component/strava/auth.d.ts +2 -1
  40. package/dist/component/strava/auth.d.ts.map +1 -1
  41. package/dist/component/strava/auth.js +6 -1
  42. package/dist/component/strava/auth.js.map +1 -1
  43. package/dist/component/strava/public.d.ts +26 -50
  44. package/dist/component/strava/public.d.ts.map +1 -1
  45. package/dist/component/strava/public.js +88 -132
  46. package/dist/component/strava/public.js.map +1 -1
  47. package/dist/component/strava/webhooks.d.ts +17 -0
  48. package/dist/component/strava/webhooks.d.ts.map +1 -0
  49. package/dist/component/strava/webhooks.js +231 -0
  50. package/dist/component/strava/webhooks.js.map +1 -0
  51. package/dist/component/utils.d.ts +10 -0
  52. package/dist/component/utils.d.ts.map +1 -1
  53. package/dist/component/utils.js.map +1 -1
  54. package/dist/component/validators/athlete.d.ts +6 -0
  55. package/dist/component/validators/athlete.d.ts.map +1 -1
  56. package/dist/component/validators/athlete.js.map +1 -1
  57. package/dist/component/validators/nutrition.d.ts +6 -0
  58. package/dist/component/validators/nutrition.d.ts.map +1 -1
  59. package/dist/component/validators/nutrition.js.map +1 -1
  60. package/dist/component/validators/shared.d.ts +3 -0
  61. package/dist/component/validators/shared.d.ts.map +1 -1
  62. package/dist/component/validators/shared.js +1 -1
  63. package/dist/component/validators/shared.js.map +1 -1
  64. package/dist/component/validators/sleep.d.ts +6 -0
  65. package/dist/component/validators/sleep.d.ts.map +1 -1
  66. package/dist/component/validators/sleep.js.map +1 -1
  67. package/dist/validators.d.ts +7 -1
  68. package/dist/validators.d.ts.map +1 -1
  69. package/dist/validators.js +6 -6
  70. package/dist/validators.js.map +1 -1
  71. package/package.json +1 -1
  72. package/src/client/garmin.ts +4 -1
  73. package/src/client/index.ts +8 -1
  74. package/src/client/strava.ts +193 -27
  75. package/src/client/types.ts +125 -0
  76. package/src/component/_generated/api.ts +2 -2
  77. package/src/component/_generated/component.ts +25 -6
  78. package/src/component/garmin/auth.ts +9 -2
  79. package/src/component/garmin/private.ts +22 -243
  80. package/src/component/garmin/public.ts +56 -54
  81. package/src/component/garmin/webhooks.ts +38 -55
  82. package/src/component/private.ts +245 -1
  83. package/src/component/strava/auth.ts +9 -2
  84. package/src/component/strava/public.ts +105 -171
  85. package/src/component/strava/webhooks.ts +312 -0
  86. package/src/component/utils.ts +11 -0
  87. package/src/component/validators/athlete.ts +6 -0
  88. package/src/component/validators/nutrition.ts +6 -0
  89. package/src/component/validators/shared.ts +5 -2
  90. package/src/component/validators/sleep.ts +6 -0
  91. package/src/validators.ts +34 -7
  92. package/dist/component/strava/private.d.ts +0 -49
  93. package/dist/component/strava/private.d.ts.map +0 -1
  94. package/dist/component/strava/private.js +0 -121
  95. package/dist/component/strava/private.js.map +0 -1
  96. package/src/component/strava/private.ts +0 -147
@@ -1,5 +1,9 @@
1
1
  import { v } from "convex/values";
2
- import { internalMutation, internalQuery } from "./_generated/server.js";
2
+ import { internalAction, internalMutation, internalQuery } from "./_generated/server.js";
3
+ import { internal } from "./_generated/api.js";
4
+ import { refreshToken as refreshGarminToken } from "./garmin/auth.js";
5
+ import { refreshToken as refreshStravaToken } from "./strava/auth.js";
6
+ import type { Doc, Id } from "./_generated/dataModel.js";
3
7
 
4
8
  // ─── Internal Connection Helpers ────────────────────────────────────────────
5
9
  // Used by component-internal operations (sync crons, data ingestion, etc.).
@@ -69,3 +73,243 @@ export const updateLastDataUpdate = internalMutation({
69
73
  });
70
74
  },
71
75
  });
76
+
77
+ // ─── Internal Pending OAuth CRUD ─────────────────────────────────────────────
78
+ // Temporary storage for in-progress OAuth flows.
79
+ // Bridges auth-URL generation and the OAuth callback for all providers.
80
+
81
+ export const storePendingOAuth = internalMutation({
82
+ args: {
83
+ provider: v.string(),
84
+ state: v.string(),
85
+ codeVerifier: v.optional(v.string()),
86
+ userId: v.string(),
87
+ },
88
+ returns: v.null(),
89
+ handler: async (ctx, args) => {
90
+ await ctx.db.insert("pendingOAuth", {
91
+ ...args,
92
+ createdAt: Date.now(),
93
+ });
94
+ return null;
95
+ },
96
+ });
97
+
98
+ export const getPendingOAuth = internalQuery({
99
+ args: { state: v.string() },
100
+ returns: v.union(
101
+ v.object({
102
+ _id: v.id("pendingOAuth"),
103
+ _creationTime: v.number(),
104
+ provider: v.string(),
105
+ state: v.string(),
106
+ codeVerifier: v.optional(v.string()),
107
+ userId: v.string(),
108
+ createdAt: v.number(),
109
+ }),
110
+ v.null(),
111
+ ),
112
+ handler: async (ctx, args) => {
113
+ return await ctx.db
114
+ .query("pendingOAuth")
115
+ .withIndex("by_state", (q) => q.eq("state", args.state))
116
+ .first();
117
+ },
118
+ });
119
+
120
+ export const deletePendingOAuth = internalMutation({
121
+ args: { state: v.string() },
122
+ returns: v.null(),
123
+ handler: async (ctx, args) => {
124
+ const pending = await ctx.db
125
+ .query("pendingOAuth")
126
+ .withIndex("by_state", (q) => q.eq("state", args.state))
127
+ .first();
128
+ if (pending) {
129
+ await ctx.db.delete(pending._id);
130
+ }
131
+ return null;
132
+ },
133
+ });
134
+
135
+ // ─── Internal Token CRUD ─────────────────────────────────────────────────────
136
+
137
+ /**
138
+ * Store or update OAuth tokens for a connection.
139
+ * Upserts by connectionId — one token record per connection.
140
+ */
141
+ export const storeTokens = internalMutation({
142
+ args: {
143
+ connectionId: v.id("connections"),
144
+ accessToken: v.string(),
145
+ refreshToken: v.string(),
146
+ expiresAt: v.number(),
147
+ },
148
+ returns: v.null(),
149
+ handler: async (ctx, args) => {
150
+ const existing = await ctx.db
151
+ .query("providerTokens")
152
+ .withIndex("by_connectionId", (q) =>
153
+ q.eq("connectionId", args.connectionId),
154
+ )
155
+ .first();
156
+
157
+ if (existing) {
158
+ await ctx.db.patch(existing._id, {
159
+ accessToken: args.accessToken,
160
+ refreshToken: args.refreshToken,
161
+ expiresAt: args.expiresAt,
162
+ });
163
+ return null;
164
+ }
165
+
166
+ await ctx.db.insert("providerTokens", args);
167
+ return null;
168
+ },
169
+ });
170
+
171
+ /**
172
+ * Get stored tokens for a connection.
173
+ */
174
+ export const getTokens = internalQuery({
175
+ args: { connectionId: v.id("connections") },
176
+ returns: v.union(
177
+ v.object({
178
+ _id: v.id("providerTokens"),
179
+ _creationTime: v.number(),
180
+ connectionId: v.id("connections"),
181
+ accessToken: v.string(),
182
+ refreshToken: v.optional(v.string()),
183
+ expiresAt: v.optional(v.number()),
184
+ }),
185
+ v.null(),
186
+ ),
187
+ handler: async (ctx, args) => {
188
+ return await ctx.db
189
+ .query("providerTokens")
190
+ .withIndex("by_connectionId", (q) =>
191
+ q.eq("connectionId", args.connectionId),
192
+ )
193
+ .first();
194
+ },
195
+ });
196
+
197
+ /**
198
+ * Delete stored tokens for a connection.
199
+ */
200
+ export const deleteTokens = internalMutation({
201
+ args: { connectionId: v.id("connections") },
202
+ returns: v.null(),
203
+ handler: async (ctx, args) => {
204
+ const existing = await ctx.db
205
+ .query("providerTokens")
206
+ .withIndex("by_connectionId", (q) =>
207
+ q.eq("connectionId", args.connectionId),
208
+ )
209
+ .first();
210
+
211
+ if (existing) {
212
+ await ctx.db.delete(existing._id);
213
+ }
214
+ return null;
215
+ },
216
+ });
217
+
218
+ // ─── Resolve Connection & Access Token ──────────────────────────────────────
219
+ // Shared across providers. Looks up the connection, retrieves stored tokens,
220
+ // auto-refreshes if expired, persists the new tokens, and returns a valid
221
+ // { connectionId, accessToken } pair ready for API calls.
222
+
223
+ const REFRESH_BUFFER_SECONDS = 600;
224
+
225
+ const refreshByProvider: Record<
226
+ string,
227
+ (opts: { clientId: string; clientSecret: string; refreshToken: string }) =>
228
+ Promise<import("./utils.js").OAuthRefreshResult>
229
+ > = {
230
+ GARMIN: refreshGarminToken,
231
+ STRAVA: refreshStravaToken,
232
+ };
233
+
234
+ export const resolveConnectionAndAccessToken = internalAction({
235
+ args: {
236
+ userId: v.string(),
237
+ provider: v.string(),
238
+ clientId: v.string(),
239
+ clientSecret: v.string(),
240
+ },
241
+ handler: async (ctx, args): Promise<{
242
+ connectionId: Id<"connections">;
243
+ accessToken: string;
244
+ }> => {
245
+ const connection: Doc<"connections"> | null = await ctx.runQuery(
246
+ internal.private.getConnectionByProvider,
247
+ { userId: args.userId, provider: args.provider },
248
+ );
249
+
250
+ if (!connection) {
251
+ throw new Error(
252
+ `No ${args.provider} connection found for user "${args.userId}". ` +
253
+ `Connect to ${args.provider} first.`,
254
+ );
255
+ }
256
+
257
+ if (!connection.active) {
258
+ throw new Error(
259
+ `${args.provider} connection for user "${args.userId}" is inactive. Reconnect first.`,
260
+ );
261
+ }
262
+
263
+ const connectionId = connection._id;
264
+
265
+ const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(
266
+ internal.private.getTokens,
267
+ { connectionId },
268
+ );
269
+
270
+ if (!tokenDoc) {
271
+ throw new Error(
272
+ `No ${args.provider} tokens found for this connection. ` +
273
+ "The connection may have been created before token storage was available.",
274
+ );
275
+ }
276
+
277
+ let accessToken = tokenDoc.accessToken;
278
+
279
+ // Refresh the token if it's expired or about to expire
280
+ const nowSeconds = Math.floor(Date.now() / 1000);
281
+ if (
282
+ tokenDoc.expiresAt &&
283
+ tokenDoc.refreshToken &&
284
+ nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
285
+ ) {
286
+ const refresh = refreshByProvider[args.provider];
287
+ if (!refresh) {
288
+ throw new Error(`No refresh handler registered for provider "${args.provider}"`);
289
+ }
290
+
291
+ const refreshed = await refresh({
292
+ clientId: args.clientId,
293
+ clientSecret: args.clientSecret,
294
+ refreshToken: tokenDoc.refreshToken,
295
+ });
296
+
297
+ accessToken = refreshed.access_token;
298
+
299
+ await ctx.runMutation(
300
+ internal.private.storeTokens,
301
+ {
302
+ connectionId,
303
+ accessToken: refreshed.access_token,
304
+ refreshToken: refreshed.refresh_token,
305
+ expiresAt: refreshed.expiresAt,
306
+ },
307
+ );
308
+ }
309
+
310
+ return {
311
+ connectionId,
312
+ accessToken,
313
+ };
314
+ },
315
+ });
@@ -2,6 +2,8 @@
2
2
  // Pure helper functions for the Strava OAuth 2.0 Authorization Code flow.
3
3
  // No external dependencies — uses the global `fetch`.
4
4
 
5
+ import type { OAuthRefreshResult } from "../utils.js";
6
+
5
7
  export interface StravaOAuthTokenResponse {
6
8
  token_type: string;
7
9
  expires_at: number; // Unix timestamp
@@ -120,7 +122,7 @@ export interface RefreshTokenOptions {
120
122
  */
121
123
  export async function refreshToken(
122
124
  opts: RefreshTokenOptions,
123
- ): Promise<StravaOAuthTokenResponse> {
125
+ ): Promise<OAuthRefreshResult> {
124
126
  const response = await fetch("https://www.strava.com/oauth/token", {
125
127
  method: "POST",
126
128
  headers: { "Content-Type": "application/json" },
@@ -139,5 +141,10 @@ export async function refreshToken(
139
141
  );
140
142
  }
141
143
 
142
- return (await response.json()) as StravaOAuthTokenResponse;
144
+ const raw = (await response.json()) as StravaOAuthTokenResponse;
145
+ return {
146
+ access_token: raw.access_token,
147
+ refresh_token: raw.refresh_token,
148
+ expiresAt: raw.expires_at,
149
+ };
143
150
  }
@@ -1,5 +1,5 @@
1
- // ─── Strava Component Actions ────────────────────────────────────────────────
2
- // Public actions that handle the full Strava OAuth + sync lifecycle.
1
+ // ─── Strava Public Actions ──────────────────────────────────────────────────
2
+ // Public actions that handle the full Strava OAuth + pull lifecycle.
3
3
  // The host app calls these through the Soma class, which threads the
4
4
  // credentials automatically from env vars or constructor config.
5
5
 
@@ -18,20 +18,13 @@ import { generateState } from "../utils.js";
18
18
  import {
19
19
  buildAuthUrl,
20
20
  exchangeCode,
21
- refreshToken as refreshStravaToken,
22
21
  } from "./auth.js";
23
22
  import { transformActivity } from "./transform/activity.js";
24
23
  import { transformAthlete } from "./transform/athlete.js";
24
+ import type { SomaError } from "../validators/shared.js";
25
25
 
26
- // ─── Public Actions ──────────────────────────────────────────────────────────
26
+ // ─── OAuth ──────────────────────────────────────────────────────────────────
27
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
28
  export const getStravaAuthUrl = action({
36
29
  args: {
37
30
  clientId: v.string(),
@@ -49,7 +42,7 @@ export const getStravaAuthUrl = action({
49
42
  state,
50
43
  });
51
44
 
52
- await ctx.runMutation(internal.strava.private.storePendingOAuth, {
45
+ await ctx.runMutation(internal.private.storePendingOAuth, {
53
46
  provider: "STRAVA",
54
47
  state,
55
48
  userId: args.userId,
@@ -59,17 +52,6 @@ export const getStravaAuthUrl = action({
59
52
  },
60
53
  });
61
54
 
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
55
  export const completeStravaOAuth = action({
74
56
  args: {
75
57
  code: v.string(),
@@ -87,7 +69,7 @@ export const completeStravaOAuth = action({
87
69
  }> => {
88
70
  // 1. Look up pending state
89
71
  const pending: Doc<"pendingOAuth"> | null = await ctx.runQuery(
90
- internal.strava.private.getPendingOAuth,
72
+ internal.private.getPendingOAuth,
91
73
  { state: args.state },
92
74
  );
93
75
  if (!pending) {
@@ -106,7 +88,7 @@ export const completeStravaOAuth = action({
106
88
 
107
89
  // 3. Clean up pending entry
108
90
  await ctx.runMutation(
109
- internal.strava.private.deletePendingOAuth,
91
+ internal.private.deletePendingOAuth,
110
92
  { state: args.state },
111
93
  );
112
94
 
@@ -116,12 +98,13 @@ export const completeStravaOAuth = action({
116
98
  {
117
99
  userId: pending.userId,
118
100
  provider: "STRAVA",
101
+ providerUserId: String(tokens.athlete.id),
119
102
  },
120
103
  );
121
104
 
122
105
  // 5. Store OAuth tokens
123
106
  await ctx.runMutation(
124
- internal.strava.private.storeTokens,
107
+ internal.private.storeTokens,
125
108
  {
126
109
  connectionId,
127
110
  accessToken: tokens.access_token,
@@ -137,31 +120,14 @@ export const completeStravaOAuth = action({
137
120
  },
138
121
  });
139
122
 
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({
123
+ export const disconnectStrava = action({
149
124
  args: {
150
125
  userId: v.string(),
151
126
  clientId: v.string(),
152
127
  clientSecret: v.string(),
153
- after: v.optional(v.number()),
154
128
  },
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
- }> => {
129
+ returns: v.null(),
130
+ handler: async (ctx, args) => {
165
131
  // 1. Look up connection
166
132
  const connection: Doc<"connections"> | null = await ctx.runQuery(
167
133
  internal.private.getConnectionByProvider,
@@ -169,110 +135,72 @@ export const syncStrava = action({
169
135
  );
170
136
  if (!connection) {
171
137
  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.`,
138
+ `No Strava connection found for user "${args.userId}".`,
179
139
  );
180
140
  }
181
141
 
182
142
  const connectionId = connection._id;
183
143
 
184
- // 2. Get stored tokens
144
+ // 2. Revoke token at Strava (best-effort, don't fail if it errors)
185
145
  const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(
186
- internal.strava.private.getTokens,
146
+ internal.private.getTokens,
187
147
  { connectionId },
188
148
  );
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
- }
149
+ if (tokenDoc) {
150
+ try {
151
+ await fetch("https://www.strava.com/oauth/deauthorize", {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
154
+ body: `access_token=${tokenDoc.accessToken}`,
155
+ });
156
+ } catch {
157
+ // Deauthorization is best-effort — clean up locally regardless
158
+ }
195
159
 
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
- },
160
+ // 3. Delete stored tokens
161
+ const _deleted: null = await ctx.runMutation(
162
+ internal.private.deleteTokens,
163
+ { connectionId },
220
164
  );
221
165
  }
222
166
 
223
- // 4. Sync all data types
224
- const result = await ctx.runAction(api.strava.public.syncAllTypes, {
225
- accessToken,
226
- connectionId,
167
+ // 4. Set connection inactive
168
+ const _disconnected: null = await ctx.runMutation(api.public.disconnect, {
227
169
  userId: args.userId,
228
- after: args.after,
170
+ provider: "STRAVA",
229
171
  });
230
172
 
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 };
173
+ return null;
241
174
  },
242
175
  });
243
176
 
244
- // ─── Sync Engine ────────────────────────────────────────────────────────────
177
+ // ─── Pull ───────────────────────────────────────────────────────────────────
245
178
 
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({
179
+ export const pullAthlete = action({
252
180
  args: {
253
- accessToken: v.string(),
254
- connectionId: v.id("connections"),
255
181
  userId: v.string(),
256
- after: v.optional(v.number()),
257
- before: v.optional(v.number()),
182
+ clientId: v.string(),
183
+ clientSecret: v.string(),
258
184
  },
259
185
  handler: async (ctx, args) => {
260
- const { accessToken, connectionId, userId } = args;
261
- const client = createStravaClient(accessToken);
186
+ const { connectionId, accessToken } = await ctx.runAction(
187
+ internal.private.resolveConnectionAndAccessToken,
188
+ { userId: args.userId, provider: "STRAVA", clientId: args.clientId, clientSecret: args.clientSecret },
189
+ );
262
190
 
263
- const synced = { athletes: 0, activities: 0 };
264
- const errors: Array<{ type: string; id: string; message: string }> = [];
191
+ const client = createStravaClient(accessToken);
192
+ const synced = { athletes: 0 };
193
+ const errors: SomaError[] = [];
265
194
 
266
- // ── Athlete ──────────────────────────────────────────────────────────
267
195
  try {
268
196
  const { data: athlete, error } = await getLoggedInAthlete({ client });
269
197
  if (error || !athlete) throw new Error(error ? JSON.stringify(error) : "No athlete data");
270
198
  const data = transformAthlete(athlete);
271
199
  await ctx.runMutation(api.public.ingestAthlete, {
272
200
  connectionId,
273
- userId,
201
+ userId: args.userId,
274
202
  ...data,
275
- } as never);
203
+ });
276
204
  synced.athletes++;
277
205
  } catch (err) {
278
206
  errors.push({
@@ -282,7 +210,33 @@ export const syncAllTypes = action({
282
210
  });
283
211
  }
284
212
 
285
- // ── Activities ───────────────────────────────────────────────────────
213
+ await ctx.runMutation(api.public.updateConnection, {
214
+ connectionId,
215
+ lastDataUpdate: new Date().toISOString(),
216
+ });
217
+
218
+ return { data: { synced }, errors };
219
+ },
220
+ });
221
+
222
+ export const pullActivities = action({
223
+ args: {
224
+ userId: v.string(),
225
+ clientId: v.string(),
226
+ clientSecret: v.string(),
227
+ after: v.optional(v.number()),
228
+ before: v.optional(v.number()),
229
+ },
230
+ handler: async (ctx, args) => {
231
+ const { connectionId, accessToken } = await ctx.runAction(
232
+ internal.private.resolveConnectionAndAccessToken,
233
+ { userId: args.userId, provider: "STRAVA", clientId: args.clientId, clientSecret: args.clientSecret },
234
+ );
235
+
236
+ const client = createStravaClient(accessToken);
237
+ const synced = { activities: 0 };
238
+ const errors: SomaError[] = [];
239
+
286
240
  try {
287
241
  const summaries = await listAllActivities(client, {
288
242
  after: args.after,
@@ -306,9 +260,9 @@ export const syncAllTypes = action({
306
260
  const data = transformActivity(detailed, { streams: streams ?? undefined });
307
261
  await ctx.runMutation(api.public.ingestActivity, {
308
262
  connectionId,
309
- userId,
263
+ userId: args.userId,
310
264
  ...data,
311
- } as never);
265
+ });
312
266
  synced.activities++;
313
267
  } catch (err) {
314
268
  errors.push({
@@ -326,68 +280,48 @@ export const syncAllTypes = action({
326
280
  });
327
281
  }
328
282
 
283
+ await ctx.runMutation(api.public.updateConnection, {
284
+ connectionId,
285
+ lastDataUpdate: new Date().toISOString(),
286
+ });
287
+
329
288
  return { data: { synced }, errors };
330
289
  },
331
290
  });
332
291
 
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({
292
+ export const pullAll = action({
342
293
  args: {
343
294
  userId: v.string(),
344
295
  clientId: v.string(),
345
296
  clientSecret: v.string(),
297
+ after: v.optional(v.number()),
298
+ before: v.optional(v.number()),
346
299
  },
347
- returns: v.null(),
348
300
  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) {
301
+ const sharedArgs = {
302
+ userId: args.userId,
303
+ clientId: args.clientId,
304
+ clientSecret: args.clientSecret,
305
+ };
306
+ const pullFns = [
307
+ { ref: api.strava.public.pullAthlete, name: "athlete", args: sharedArgs },
308
+ { ref: api.strava.public.pullActivities, name: "activities", args: { ...sharedArgs, after: args.after, before: args.before } },
309
+ ];
310
+ const synced: Record<string, number> = {};
311
+ const errors: SomaError[] = [];
312
+ for (const { ref, name, args: fnArgs } of pullFns) {
368
313
  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}`,
314
+ const result = await ctx.runAction(ref, fnArgs);
315
+ Object.assign(synced, result.data.synced);
316
+ errors.push(...result.errors);
317
+ } catch (err) {
318
+ errors.push({
319
+ type: name,
320
+ id: "pull",
321
+ message: err instanceof Error ? err.message : String(err),
373
322
  });
374
- } catch {
375
- // Deauthorization is best-effort — clean up locally regardless
376
323
  }
377
-
378
- // 3. Delete stored tokens
379
- const _deleted: null = await ctx.runMutation(
380
- internal.strava.private.deleteTokens,
381
- { connectionId },
382
- );
383
324
  }
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;
325
+ return { data: { synced }, errors };
392
326
  },
393
- });
327
+ });