@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.
- package/dist/client/garmin.d.ts +4 -1
- package/dist/client/garmin.d.ts.map +1 -1
- package/dist/client/garmin.js +4 -1
- package/dist/client/garmin.js.map +1 -1
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/strava.d.ts +48 -34
- package/dist/client/strava.d.ts.map +1 -1
- package/dist/client/strava.js +141 -23
- package/dist/client/strava.js.map +1 -1
- package/dist/client/types.d.ts +108 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/_generated/api.d.ts +2 -2
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +19 -17
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin/auth.d.ts +2 -1
- package/dist/component/garmin/auth.d.ts.map +1 -1
- package/dist/component/garmin/auth.js +6 -1
- package/dist/component/garmin/auth.js.map +1 -1
- package/dist/component/garmin/private.d.ts +17 -75
- package/dist/component/garmin/private.d.ts.map +1 -1
- package/dist/component/garmin/private.js +4 -167
- package/dist/component/garmin/private.js.map +1 -1
- package/dist/component/garmin/public.d.ts +18 -33
- package/dist/component/garmin/public.d.ts.map +1 -1
- package/dist/component/garmin/public.js +23 -22
- package/dist/component/garmin/public.js.map +1 -1
- package/dist/component/garmin/webhooks.d.ts +3 -6
- package/dist/component/garmin/webhooks.d.ts.map +1 -1
- package/dist/component/garmin/webhooks.js +17 -28
- package/dist/component/garmin/webhooks.js.map +1 -1
- package/dist/component/private.d.ts +59 -0
- package/dist/component/private.d.ts.map +1 -1
- package/dist/component/private.js +182 -1
- package/dist/component/private.js.map +1 -1
- package/dist/component/strava/auth.d.ts +2 -1
- package/dist/component/strava/auth.d.ts.map +1 -1
- package/dist/component/strava/auth.js +6 -1
- package/dist/component/strava/auth.js.map +1 -1
- package/dist/component/strava/public.d.ts +26 -50
- package/dist/component/strava/public.d.ts.map +1 -1
- package/dist/component/strava/public.js +88 -132
- package/dist/component/strava/public.js.map +1 -1
- package/dist/component/strava/webhooks.d.ts +17 -0
- package/dist/component/strava/webhooks.d.ts.map +1 -0
- package/dist/component/strava/webhooks.js +231 -0
- package/dist/component/strava/webhooks.js.map +1 -0
- package/dist/component/utils.d.ts +10 -0
- package/dist/component/utils.d.ts.map +1 -1
- package/dist/component/utils.js.map +1 -1
- package/dist/component/validators/athlete.d.ts +6 -0
- package/dist/component/validators/athlete.d.ts.map +1 -1
- package/dist/component/validators/athlete.js.map +1 -1
- package/dist/component/validators/nutrition.d.ts +6 -0
- package/dist/component/validators/nutrition.d.ts.map +1 -1
- package/dist/component/validators/nutrition.js.map +1 -1
- package/dist/component/validators/shared.d.ts +3 -0
- package/dist/component/validators/shared.d.ts.map +1 -1
- package/dist/component/validators/shared.js +1 -1
- package/dist/component/validators/shared.js.map +1 -1
- package/dist/component/validators/sleep.d.ts +6 -0
- package/dist/component/validators/sleep.d.ts.map +1 -1
- package/dist/component/validators/sleep.js.map +1 -1
- package/dist/validators.d.ts +7 -1
- package/dist/validators.d.ts.map +1 -1
- package/dist/validators.js +6 -6
- package/dist/validators.js.map +1 -1
- package/package.json +1 -1
- package/src/client/garmin.ts +4 -1
- package/src/client/index.ts +8 -1
- package/src/client/strava.ts +193 -27
- package/src/client/types.ts +125 -0
- package/src/component/_generated/api.ts +2 -2
- package/src/component/_generated/component.ts +25 -6
- package/src/component/garmin/auth.ts +9 -2
- package/src/component/garmin/private.ts +22 -243
- package/src/component/garmin/public.ts +56 -54
- package/src/component/garmin/webhooks.ts +38 -55
- package/src/component/private.ts +245 -1
- package/src/component/strava/auth.ts +9 -2
- package/src/component/strava/public.ts +105 -171
- package/src/component/strava/webhooks.ts +312 -0
- package/src/component/utils.ts +11 -0
- package/src/component/validators/athlete.ts +6 -0
- package/src/component/validators/nutrition.ts +6 -0
- package/src/component/validators/shared.ts +5 -2
- package/src/component/validators/sleep.ts +6 -0
- package/src/validators.ts +34 -7
- package/dist/component/strava/private.d.ts +0 -49
- package/dist/component/strava/private.d.ts.map +0 -1
- package/dist/component/strava/private.js +0 -121
- package/dist/component/strava/private.js.map +0 -1
- package/src/component/strava/private.ts +0 -147
package/src/component/private.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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
|
|
2
|
-
// Public actions that handle the full Strava OAuth +
|
|
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
|
-
// ───
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
156
|
-
|
|
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.
|
|
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.
|
|
146
|
+
internal.private.getTokens,
|
|
187
147
|
{ connectionId },
|
|
188
148
|
);
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
224
|
-
const
|
|
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
|
-
|
|
170
|
+
provider: "STRAVA",
|
|
229
171
|
});
|
|
230
172
|
|
|
231
|
-
|
|
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
|
-
// ───
|
|
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
|
-
|
|
257
|
-
|
|
182
|
+
clientId: v.string(),
|
|
183
|
+
clientSecret: v.string(),
|
|
258
184
|
},
|
|
259
185
|
handler: async (ctx, args) => {
|
|
260
|
-
const {
|
|
261
|
-
|
|
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
|
|
264
|
-
const
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
});
|