@nativesquare/soma 0.5.0 → 0.7.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/index.d.ts +151 -53
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +162 -69
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +130 -17
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +61 -43
- package/dist/component/garmin.d.ts.map +1 -1
- package/dist/component/garmin.js +208 -122
- package/dist/component/garmin.js.map +1 -1
- package/dist/component/public.d.ts +363 -0
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +124 -0
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +7 -9
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +9 -10
- package/dist/component/schema.js.map +1 -1
- package/dist/component/strava.d.ts +0 -1
- package/dist/component/strava.d.ts.map +1 -1
- package/dist/component/strava.js +0 -1
- package/dist/component/strava.js.map +1 -1
- package/dist/component/validators/enums.d.ts +1 -1
- package/dist/garmin/auth.d.ts +55 -46
- package/dist/garmin/auth.d.ts.map +1 -1
- package/dist/garmin/auth.js +82 -122
- package/dist/garmin/auth.js.map +1 -1
- package/dist/garmin/client.d.ts +64 -17
- package/dist/garmin/client.d.ts.map +1 -1
- package/dist/garmin/client.js +143 -29
- package/dist/garmin/client.js.map +1 -1
- package/dist/garmin/index.d.ts +3 -3
- package/dist/garmin/index.d.ts.map +1 -1
- package/dist/garmin/index.js +4 -4
- package/dist/garmin/index.js.map +1 -1
- package/dist/garmin/plannedWorkout.d.ts +12 -0
- package/dist/garmin/plannedWorkout.d.ts.map +1 -0
- package/dist/garmin/plannedWorkout.js +267 -0
- package/dist/garmin/plannedWorkout.js.map +1 -0
- package/dist/garmin/types.d.ts +78 -6
- package/dist/garmin/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +236 -85
- package/src/component/_generated/component.ts +155 -17
- package/src/component/garmin.ts +258 -124
- package/src/component/public.ts +135 -0
- package/src/component/schema.ts +9 -10
- package/src/component/strava.ts +0 -1
- package/src/garmin/auth.test.ts +71 -96
- package/src/garmin/auth.ts +129 -193
- package/src/garmin/client.ts +197 -51
- package/src/garmin/index.ts +13 -14
- package/src/garmin/plannedWorkout.ts +333 -0
- package/src/garmin/types.ts +149 -7
package/src/component/garmin.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ─── Garmin Component Actions ────────────────────────────────────────────────
|
|
2
|
-
// Public actions that handle the full Garmin OAuth + sync lifecycle.
|
|
2
|
+
// Public actions that handle the full Garmin OAuth 2.0 PKCE + sync 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
|
//
|
|
@@ -12,13 +12,21 @@ import {
|
|
|
12
12
|
internalMutation,
|
|
13
13
|
internalQuery,
|
|
14
14
|
} from "./_generated/server.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
generateCodeVerifier,
|
|
17
|
+
generateCodeChallenge,
|
|
18
|
+
generateState,
|
|
19
|
+
buildAuthUrl,
|
|
20
|
+
exchangeCode,
|
|
21
|
+
refreshToken,
|
|
22
|
+
} from "../garmin/auth.js";
|
|
16
23
|
import { GarminClient } from "../garmin/client.js";
|
|
17
24
|
import { transformActivity } from "../garmin/activity.js";
|
|
18
25
|
import { transformDaily } from "../garmin/daily.js";
|
|
19
26
|
import { transformSleep } from "../garmin/sleep.js";
|
|
20
27
|
import { transformBody } from "../garmin/body.js";
|
|
21
28
|
import { transformMenstruation } from "../garmin/menstruation.js";
|
|
29
|
+
import { transformPlannedWorkoutToGarmin } from "../garmin/plannedWorkout.js";
|
|
22
30
|
|
|
23
31
|
// Use anyApi to avoid circular type references between this file and _generated/api.ts.
|
|
24
32
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -29,15 +37,18 @@ const internalApi: any = anyApi;
|
|
|
29
37
|
// Default sync window: last 30 days
|
|
30
38
|
const DEFAULT_SYNC_DAYS = 30;
|
|
31
39
|
|
|
40
|
+
// Refresh buffer: refresh tokens 10 minutes before expiry
|
|
41
|
+
const REFRESH_BUFFER_SECONDS = 600;
|
|
42
|
+
|
|
32
43
|
// ─── Internal Pending OAuth CRUD ─────────────────────────────────────────────
|
|
33
|
-
// Temporary storage for in-progress Garmin OAuth
|
|
34
|
-
// Bridges
|
|
44
|
+
// Temporary storage for in-progress Garmin OAuth 2.0 PKCE flows.
|
|
45
|
+
// Bridges getGarminAuthUrl and completeGarminOAuth.
|
|
35
46
|
|
|
36
47
|
export const storePendingOAuth = internalMutation({
|
|
37
48
|
args: {
|
|
38
49
|
provider: v.string(),
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
state: v.string(),
|
|
51
|
+
codeVerifier: v.string(),
|
|
41
52
|
userId: v.string(),
|
|
42
53
|
},
|
|
43
54
|
returns: v.null(),
|
|
@@ -51,14 +62,14 @@ export const storePendingOAuth = internalMutation({
|
|
|
51
62
|
});
|
|
52
63
|
|
|
53
64
|
export const getPendingOAuth = internalQuery({
|
|
54
|
-
args: {
|
|
65
|
+
args: { state: v.string() },
|
|
55
66
|
returns: v.union(
|
|
56
67
|
v.object({
|
|
57
68
|
_id: v.id("pendingOAuth"),
|
|
58
69
|
_creationTime: v.number(),
|
|
59
70
|
provider: v.string(),
|
|
60
|
-
|
|
61
|
-
|
|
71
|
+
state: v.string(),
|
|
72
|
+
codeVerifier: v.string(),
|
|
62
73
|
userId: v.string(),
|
|
63
74
|
createdAt: v.number(),
|
|
64
75
|
}),
|
|
@@ -67,18 +78,18 @@ export const getPendingOAuth = internalQuery({
|
|
|
67
78
|
handler: async (ctx, args) => {
|
|
68
79
|
return await ctx.db
|
|
69
80
|
.query("pendingOAuth")
|
|
70
|
-
.withIndex("
|
|
81
|
+
.withIndex("by_state", (q) => q.eq("state", args.state))
|
|
71
82
|
.first();
|
|
72
83
|
},
|
|
73
84
|
});
|
|
74
85
|
|
|
75
86
|
export const deletePendingOAuth = internalMutation({
|
|
76
|
-
args: {
|
|
87
|
+
args: { state: v.string() },
|
|
77
88
|
returns: v.null(),
|
|
78
89
|
handler: async (ctx, args) => {
|
|
79
90
|
const pending = await ctx.db
|
|
80
91
|
.query("pendingOAuth")
|
|
81
|
-
.withIndex("
|
|
92
|
+
.withIndex("by_state", (q) => q.eq("state", args.state))
|
|
82
93
|
.first();
|
|
83
94
|
if (pending) {
|
|
84
95
|
await ctx.db.delete(pending._id);
|
|
@@ -90,14 +101,15 @@ export const deletePendingOAuth = internalMutation({
|
|
|
90
101
|
// ─── Internal Token CRUD ─────────────────────────────────────────────────────
|
|
91
102
|
|
|
92
103
|
/**
|
|
93
|
-
* Store OAuth
|
|
104
|
+
* Store OAuth 2.0 tokens for a Garmin connection.
|
|
94
105
|
* Upserts by connectionId — one token record per connection.
|
|
95
106
|
*/
|
|
96
107
|
export const storeTokens = internalMutation({
|
|
97
108
|
args: {
|
|
98
109
|
connectionId: v.id("connections"),
|
|
99
110
|
accessToken: v.string(),
|
|
100
|
-
|
|
111
|
+
refreshToken: v.string(),
|
|
112
|
+
expiresAt: v.number(),
|
|
101
113
|
},
|
|
102
114
|
returns: v.null(),
|
|
103
115
|
handler: async (ctx, args) => {
|
|
@@ -111,7 +123,8 @@ export const storeTokens = internalMutation({
|
|
|
111
123
|
if (existing) {
|
|
112
124
|
await ctx.db.patch(existing._id, {
|
|
113
125
|
accessToken: args.accessToken,
|
|
114
|
-
|
|
126
|
+
refreshToken: args.refreshToken,
|
|
127
|
+
expiresAt: args.expiresAt,
|
|
115
128
|
});
|
|
116
129
|
return null;
|
|
117
130
|
}
|
|
@@ -119,7 +132,8 @@ export const storeTokens = internalMutation({
|
|
|
119
132
|
await ctx.db.insert("providerTokens", {
|
|
120
133
|
connectionId: args.connectionId,
|
|
121
134
|
accessToken: args.accessToken,
|
|
122
|
-
|
|
135
|
+
refreshToken: args.refreshToken,
|
|
136
|
+
expiresAt: args.expiresAt,
|
|
123
137
|
});
|
|
124
138
|
return null;
|
|
125
139
|
},
|
|
@@ -137,7 +151,6 @@ export const getTokens = internalQuery({
|
|
|
137
151
|
connectionId: v.id("connections"),
|
|
138
152
|
accessToken: v.string(),
|
|
139
153
|
refreshToken: v.optional(v.string()),
|
|
140
|
-
tokenSecret: v.optional(v.string()),
|
|
141
154
|
expiresAt: v.optional(v.number()),
|
|
142
155
|
}),
|
|
143
156
|
v.null(),
|
|
@@ -176,67 +189,66 @@ export const deleteTokens = internalMutation({
|
|
|
176
189
|
// ─── Public Actions ──────────────────────────────────────────────────────────
|
|
177
190
|
|
|
178
191
|
/**
|
|
179
|
-
*
|
|
192
|
+
* Generate a Garmin OAuth 2.0 authorization URL with PKCE.
|
|
180
193
|
*
|
|
181
|
-
* If `userId` is provided, the
|
|
194
|
+
* If `userId` is provided, the PKCE code verifier and state are stored in the
|
|
182
195
|
* component's `pendingOAuth` table so that `completeGarminOAuth` can look
|
|
183
196
|
* them up automatically when the callback fires. This is the recommended
|
|
184
197
|
* flow when using `registerRoutes`.
|
|
185
198
|
*
|
|
186
|
-
* If `userId` is omitted, the host app must store the returned `
|
|
187
|
-
*
|
|
199
|
+
* If `userId` is omitted, the host app must store the returned `codeVerifier`
|
|
200
|
+
* itself and pass it to `connectGarmin` manually.
|
|
188
201
|
*/
|
|
189
|
-
export const
|
|
202
|
+
export const getGarminAuthUrl = action({
|
|
190
203
|
args: {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
callbackUrl: v.optional(v.string()),
|
|
204
|
+
clientId: v.string(),
|
|
205
|
+
redirectUri: v.optional(v.string()),
|
|
194
206
|
userId: v.optional(v.string()),
|
|
195
207
|
},
|
|
196
208
|
returns: v.object({
|
|
197
|
-
token: v.string(),
|
|
198
|
-
tokenSecret: v.string(),
|
|
199
209
|
authUrl: v.string(),
|
|
210
|
+
state: v.string(),
|
|
211
|
+
codeVerifier: v.string(),
|
|
200
212
|
}),
|
|
201
213
|
handler: async (ctx, args) => {
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
214
|
+
const codeVerifier = generateCodeVerifier();
|
|
215
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
216
|
+
const state = generateState();
|
|
217
|
+
|
|
218
|
+
const authUrl = buildAuthUrl({
|
|
219
|
+
clientId: args.clientId,
|
|
220
|
+
codeChallenge,
|
|
221
|
+
redirectUri: args.redirectUri,
|
|
222
|
+
state,
|
|
206
223
|
});
|
|
207
224
|
|
|
208
225
|
if (args.userId) {
|
|
209
226
|
await ctx.runMutation(internalApi.garmin.storePendingOAuth, {
|
|
210
227
|
provider: "GARMIN",
|
|
211
|
-
|
|
212
|
-
|
|
228
|
+
state,
|
|
229
|
+
codeVerifier,
|
|
213
230
|
userId: args.userId,
|
|
214
231
|
});
|
|
215
232
|
}
|
|
216
233
|
|
|
217
|
-
return {
|
|
218
|
-
token: result.oauthToken,
|
|
219
|
-
tokenSecret: result.oauthTokenSecret,
|
|
220
|
-
authUrl: result.authUrl,
|
|
221
|
-
};
|
|
234
|
+
return { authUrl, state, codeVerifier };
|
|
222
235
|
},
|
|
223
236
|
});
|
|
224
237
|
|
|
225
238
|
/**
|
|
226
|
-
*
|
|
239
|
+
* Exchange an authorization code for tokens + initial sync.
|
|
227
240
|
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* the last 30 days of all data types.
|
|
241
|
+
* Used in the manual flow where the host app stores the code verifier
|
|
242
|
+
* and handles the callback itself.
|
|
231
243
|
*/
|
|
232
244
|
export const connectGarmin = action({
|
|
233
245
|
args: {
|
|
234
246
|
userId: v.string(),
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
247
|
+
clientId: v.string(),
|
|
248
|
+
clientSecret: v.string(),
|
|
249
|
+
code: v.string(),
|
|
250
|
+
codeVerifier: v.string(),
|
|
251
|
+
redirectUri: v.optional(v.string()),
|
|
240
252
|
},
|
|
241
253
|
returns: v.object({
|
|
242
254
|
connectionId: v.string(),
|
|
@@ -252,34 +264,29 @@ export const connectGarmin = action({
|
|
|
252
264
|
),
|
|
253
265
|
}),
|
|
254
266
|
handler: async (ctx, args) => {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
verifier: args.verifier,
|
|
267
|
+
const tokenResult = await exchangeCode({
|
|
268
|
+
clientId: args.clientId,
|
|
269
|
+
clientSecret: args.clientSecret,
|
|
270
|
+
code: args.code,
|
|
271
|
+
codeVerifier: args.codeVerifier,
|
|
272
|
+
redirectUri: args.redirectUri,
|
|
262
273
|
});
|
|
263
274
|
|
|
264
|
-
// 2. Create/reactivate the Soma connection
|
|
265
275
|
const connectionId = await ctx.runMutation(publicApi.public.connect, {
|
|
266
276
|
userId: args.userId,
|
|
267
277
|
provider: "GARMIN",
|
|
268
278
|
});
|
|
269
279
|
|
|
270
|
-
|
|
280
|
+
const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
|
|
271
281
|
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
272
282
|
connectionId,
|
|
273
|
-
accessToken:
|
|
274
|
-
|
|
283
|
+
accessToken: tokenResult.access_token,
|
|
284
|
+
refreshToken: tokenResult.refresh_token,
|
|
285
|
+
expiresAt,
|
|
275
286
|
});
|
|
276
287
|
|
|
277
|
-
// 4. Sync last 30 days of all data types
|
|
278
288
|
const client = new GarminClient({
|
|
279
|
-
|
|
280
|
-
consumerSecret: args.consumerSecret,
|
|
281
|
-
accessToken: accessTokenResult.oauthToken,
|
|
282
|
-
tokenSecret: accessTokenResult.oauthTokenSecret,
|
|
289
|
+
accessToken: tokenResult.access_token,
|
|
283
290
|
});
|
|
284
291
|
|
|
285
292
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -292,12 +299,9 @@ export const connectGarmin = action({
|
|
|
292
299
|
const result = await syncAllTypes(ctx, client, {
|
|
293
300
|
connectionId,
|
|
294
301
|
userId: args.userId,
|
|
295
|
-
consumerKey: args.consumerKey,
|
|
296
|
-
consumerSecret: args.consumerSecret,
|
|
297
302
|
timeRange,
|
|
298
303
|
});
|
|
299
304
|
|
|
300
|
-
// 5. Update lastDataUpdate timestamp
|
|
301
305
|
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
302
306
|
connectionId,
|
|
303
307
|
lastDataUpdate: new Date().toISOString(),
|
|
@@ -312,20 +316,21 @@ export const connectGarmin = action({
|
|
|
312
316
|
});
|
|
313
317
|
|
|
314
318
|
/**
|
|
315
|
-
* Complete a Garmin OAuth flow using stored pending state.
|
|
319
|
+
* Complete a Garmin OAuth 2.0 flow using stored pending state.
|
|
316
320
|
*
|
|
317
321
|
* Used by `registerRoutes` — the callback handler calls this with the
|
|
318
|
-
* `
|
|
319
|
-
*
|
|
320
|
-
* exchanges for
|
|
321
|
-
*
|
|
322
|
+
* `code` and `state` from the redirect. The action looks up the pending
|
|
323
|
+
* state (codeVerifier, userId) stored during `getGarminAuthUrl`,
|
|
324
|
+
* exchanges for tokens, creates the connection, syncs data, and
|
|
325
|
+
* cleans up the pending entry.
|
|
322
326
|
*/
|
|
323
327
|
export const completeGarminOAuth = action({
|
|
324
328
|
args: {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
+
code: v.string(),
|
|
330
|
+
state: v.string(),
|
|
331
|
+
clientId: v.string(),
|
|
332
|
+
clientSecret: v.string(),
|
|
333
|
+
redirectUri: v.optional(v.string()),
|
|
329
334
|
},
|
|
330
335
|
returns: v.object({
|
|
331
336
|
connectionId: v.string(),
|
|
@@ -341,50 +346,43 @@ export const completeGarminOAuth = action({
|
|
|
341
346
|
),
|
|
342
347
|
}),
|
|
343
348
|
handler: async (ctx, args) => {
|
|
344
|
-
// 1. Look up pending state
|
|
345
349
|
const pending = await ctx.runQuery(internalApi.garmin.getPendingOAuth, {
|
|
346
|
-
|
|
350
|
+
state: args.state,
|
|
347
351
|
});
|
|
348
352
|
if (!pending) {
|
|
349
353
|
throw new Error(
|
|
350
|
-
"No pending Garmin OAuth state found for this
|
|
351
|
-
"The
|
|
354
|
+
"No pending Garmin OAuth state found for this state parameter. " +
|
|
355
|
+
"The authorization may have expired or was already used.",
|
|
352
356
|
);
|
|
353
357
|
}
|
|
354
358
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
verifier: args.oauthVerifier,
|
|
359
|
+
const tokenResult = await exchangeCode({
|
|
360
|
+
clientId: args.clientId,
|
|
361
|
+
clientSecret: args.clientSecret,
|
|
362
|
+
code: args.code,
|
|
363
|
+
codeVerifier: pending.codeVerifier,
|
|
364
|
+
redirectUri: args.redirectUri,
|
|
362
365
|
});
|
|
363
366
|
|
|
364
|
-
// 3. Delete pending state (no longer needed)
|
|
365
367
|
await ctx.runMutation(internalApi.garmin.deletePendingOAuth, {
|
|
366
|
-
|
|
368
|
+
state: args.state,
|
|
367
369
|
});
|
|
368
370
|
|
|
369
|
-
// 4. Create/reactivate the Soma connection
|
|
370
371
|
const connectionId = await ctx.runMutation(publicApi.public.connect, {
|
|
371
372
|
userId: pending.userId,
|
|
372
373
|
provider: "GARMIN",
|
|
373
374
|
});
|
|
374
375
|
|
|
375
|
-
|
|
376
|
+
const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
|
|
376
377
|
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
377
378
|
connectionId,
|
|
378
|
-
accessToken:
|
|
379
|
-
|
|
379
|
+
accessToken: tokenResult.access_token,
|
|
380
|
+
refreshToken: tokenResult.refresh_token,
|
|
381
|
+
expiresAt,
|
|
380
382
|
});
|
|
381
383
|
|
|
382
|
-
// 6. Sync last 30 days of all data types
|
|
383
384
|
const client = new GarminClient({
|
|
384
|
-
|
|
385
|
-
consumerSecret: args.consumerSecret,
|
|
386
|
-
accessToken: accessTokenResult.oauthToken,
|
|
387
|
-
tokenSecret: accessTokenResult.oauthTokenSecret,
|
|
385
|
+
accessToken: tokenResult.access_token,
|
|
388
386
|
});
|
|
389
387
|
|
|
390
388
|
const now = Math.floor(Date.now() / 1000);
|
|
@@ -397,12 +395,9 @@ export const completeGarminOAuth = action({
|
|
|
397
395
|
const result = await syncAllTypes(ctx, client, {
|
|
398
396
|
connectionId,
|
|
399
397
|
userId: pending.userId,
|
|
400
|
-
consumerKey: args.consumerKey,
|
|
401
|
-
consumerSecret: args.consumerSecret,
|
|
402
398
|
timeRange,
|
|
403
399
|
});
|
|
404
400
|
|
|
405
|
-
// 7. Update lastDataUpdate timestamp
|
|
406
401
|
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
407
402
|
connectionId,
|
|
408
403
|
lastDataUpdate: new Date().toISOString(),
|
|
@@ -419,14 +414,14 @@ export const completeGarminOAuth = action({
|
|
|
419
414
|
/**
|
|
420
415
|
* Incremental Garmin sync for an already-connected user.
|
|
421
416
|
*
|
|
422
|
-
* Looks up the stored tokens and syncs all data
|
|
423
|
-
* time range (defaults to last 30 days).
|
|
417
|
+
* Looks up the stored tokens, refreshes if expired, and syncs all data
|
|
418
|
+
* types for the specified time range (defaults to last 30 days).
|
|
424
419
|
*/
|
|
425
420
|
export const syncGarmin = action({
|
|
426
421
|
args: {
|
|
427
422
|
userId: v.string(),
|
|
428
|
-
|
|
429
|
-
|
|
423
|
+
clientId: v.string(),
|
|
424
|
+
clientSecret: v.string(),
|
|
430
425
|
startTimeInSeconds: v.optional(v.number()),
|
|
431
426
|
endTimeInSeconds: v.optional(v.number()),
|
|
432
427
|
},
|
|
@@ -443,7 +438,6 @@ export const syncGarmin = action({
|
|
|
443
438
|
),
|
|
444
439
|
}),
|
|
445
440
|
handler: async (ctx, args) => {
|
|
446
|
-
// 1. Look up connection
|
|
447
441
|
const connection = await ctx.runQuery(
|
|
448
442
|
internalApi.private.getConnectionByProvider,
|
|
449
443
|
{ userId: args.userId, provider: "GARMIN" },
|
|
@@ -462,24 +456,42 @@ export const syncGarmin = action({
|
|
|
462
456
|
|
|
463
457
|
const connectionId = connection._id;
|
|
464
458
|
|
|
465
|
-
// 2. Get stored tokens
|
|
466
459
|
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
467
460
|
connectionId,
|
|
468
461
|
});
|
|
469
|
-
if (!tokenDoc
|
|
462
|
+
if (!tokenDoc) {
|
|
470
463
|
throw new Error(
|
|
471
464
|
"No Garmin tokens found for this connection. " +
|
|
472
465
|
"The connection may have been created before token storage was available.",
|
|
473
466
|
);
|
|
474
467
|
}
|
|
475
468
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
469
|
+
let accessToken = tokenDoc.accessToken;
|
|
470
|
+
|
|
471
|
+
// Refresh the token if it's expired or about to expire
|
|
472
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
473
|
+
if (
|
|
474
|
+
tokenDoc.expiresAt &&
|
|
475
|
+
tokenDoc.refreshToken &&
|
|
476
|
+
nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
|
|
477
|
+
) {
|
|
478
|
+
const refreshed = await refreshToken({
|
|
479
|
+
clientId: args.clientId,
|
|
480
|
+
clientSecret: args.clientSecret,
|
|
481
|
+
refreshToken: tokenDoc.refreshToken,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
accessToken = refreshed.access_token;
|
|
485
|
+
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
486
|
+
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
487
|
+
connectionId,
|
|
488
|
+
accessToken: refreshed.access_token,
|
|
489
|
+
refreshToken: refreshed.refresh_token,
|
|
490
|
+
expiresAt: newExpiresAt,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const client = new GarminClient({ accessToken });
|
|
483
495
|
|
|
484
496
|
const now = Math.floor(Date.now() / 1000);
|
|
485
497
|
const timeRange = {
|
|
@@ -491,12 +503,9 @@ export const syncGarmin = action({
|
|
|
491
503
|
const result = await syncAllTypes(ctx, client, {
|
|
492
504
|
connectionId,
|
|
493
505
|
userId: args.userId,
|
|
494
|
-
consumerKey: args.consumerKey,
|
|
495
|
-
consumerSecret: args.consumerSecret,
|
|
496
506
|
timeRange,
|
|
497
507
|
});
|
|
498
508
|
|
|
499
|
-
// 4. Update lastDataUpdate timestamp
|
|
500
509
|
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
501
510
|
connectionId,
|
|
502
511
|
lastDataUpdate: new Date().toISOString(),
|
|
@@ -509,8 +518,8 @@ export const syncGarmin = action({
|
|
|
509
518
|
/**
|
|
510
519
|
* Disconnect a user from Garmin.
|
|
511
520
|
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
521
|
+
* Deregisters the user via the Garmin API (best-effort), deletes stored
|
|
522
|
+
* tokens, and sets the connection to inactive.
|
|
514
523
|
*/
|
|
515
524
|
export const disconnectGarmin = action({
|
|
516
525
|
args: {
|
|
@@ -518,7 +527,6 @@ export const disconnectGarmin = action({
|
|
|
518
527
|
},
|
|
519
528
|
returns: v.null(),
|
|
520
529
|
handler: async (ctx, args) => {
|
|
521
|
-
// 1. Look up connection
|
|
522
530
|
const connection = await ctx.runQuery(
|
|
523
531
|
internalApi.private.getConnectionByProvider,
|
|
524
532
|
{ userId: args.userId, provider: "GARMIN" },
|
|
@@ -531,10 +539,21 @@ export const disconnectGarmin = action({
|
|
|
531
539
|
|
|
532
540
|
const connectionId = connection._id;
|
|
533
541
|
|
|
534
|
-
//
|
|
542
|
+
// Best-effort: deregister user at Garmin
|
|
543
|
+
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
544
|
+
connectionId,
|
|
545
|
+
});
|
|
546
|
+
if (tokenDoc) {
|
|
547
|
+
try {
|
|
548
|
+
const client = new GarminClient({ accessToken: tokenDoc.accessToken });
|
|
549
|
+
await client.deleteUserRegistration();
|
|
550
|
+
} catch {
|
|
551
|
+
// Deregistration is best-effort; proceed with local cleanup
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
535
555
|
await ctx.runMutation(internalApi.garmin.deleteTokens, { connectionId });
|
|
536
556
|
|
|
537
|
-
// 3. Set connection inactive
|
|
538
557
|
await ctx.runMutation(publicApi.public.disconnect, {
|
|
539
558
|
userId: args.userId,
|
|
540
559
|
provider: "GARMIN",
|
|
@@ -544,13 +563,128 @@ export const disconnectGarmin = action({
|
|
|
544
563
|
},
|
|
545
564
|
});
|
|
546
565
|
|
|
566
|
+
// ─── Training API ────────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Push a planned workout from Soma's DB to Garmin Connect.
|
|
570
|
+
*
|
|
571
|
+
* Reads the planned workout document, transforms it to Garmin Training API V2
|
|
572
|
+
* format, creates the workout at Garmin, and optionally schedules it if a
|
|
573
|
+
* `planned_date` is set in the metadata.
|
|
574
|
+
*
|
|
575
|
+
* Returns the Garmin workout ID and schedule ID (if scheduled).
|
|
576
|
+
*/
|
|
577
|
+
export const pushPlannedWorkout = action({
|
|
578
|
+
args: {
|
|
579
|
+
userId: v.string(),
|
|
580
|
+
clientId: v.string(),
|
|
581
|
+
clientSecret: v.string(),
|
|
582
|
+
plannedWorkoutId: v.string(),
|
|
583
|
+
workoutProvider: v.optional(v.string()),
|
|
584
|
+
},
|
|
585
|
+
returns: v.object({
|
|
586
|
+
garminWorkoutId: v.number(),
|
|
587
|
+
garminScheduleId: v.union(v.number(), v.null()),
|
|
588
|
+
}),
|
|
589
|
+
handler: async (ctx, args) => {
|
|
590
|
+
const connection = await ctx.runQuery(
|
|
591
|
+
internalApi.private.getConnectionByProvider,
|
|
592
|
+
{ userId: args.userId, provider: "GARMIN" },
|
|
593
|
+
);
|
|
594
|
+
if (!connection) {
|
|
595
|
+
throw new Error(
|
|
596
|
+
`No Garmin connection found for user "${args.userId}". ` +
|
|
597
|
+
"Call connectGarmin first.",
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
if (!connection.active) {
|
|
601
|
+
throw new Error(
|
|
602
|
+
`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const connectionId = connection._id;
|
|
607
|
+
|
|
608
|
+
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
609
|
+
connectionId,
|
|
610
|
+
});
|
|
611
|
+
if (!tokenDoc) {
|
|
612
|
+
throw new Error(
|
|
613
|
+
"No Garmin tokens found for this connection. " +
|
|
614
|
+
"The connection may have been created before token storage was available.",
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
let accessToken = tokenDoc.accessToken;
|
|
619
|
+
|
|
620
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
621
|
+
if (
|
|
622
|
+
tokenDoc.expiresAt &&
|
|
623
|
+
tokenDoc.refreshToken &&
|
|
624
|
+
nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
|
|
625
|
+
) {
|
|
626
|
+
const refreshed = await refreshToken({
|
|
627
|
+
clientId: args.clientId,
|
|
628
|
+
clientSecret: args.clientSecret,
|
|
629
|
+
refreshToken: tokenDoc.refreshToken,
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
accessToken = refreshed.access_token;
|
|
633
|
+
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
634
|
+
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
635
|
+
connectionId,
|
|
636
|
+
accessToken: refreshed.access_token,
|
|
637
|
+
refreshToken: refreshed.refresh_token,
|
|
638
|
+
expiresAt: newExpiresAt,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const plannedWorkout = await ctx.runQuery(
|
|
643
|
+
publicApi.public.getPlannedWorkout,
|
|
644
|
+
{ plannedWorkoutId: args.plannedWorkoutId as never },
|
|
645
|
+
);
|
|
646
|
+
if (!plannedWorkout) {
|
|
647
|
+
throw new Error(
|
|
648
|
+
`Planned workout "${args.plannedWorkoutId}" not found.`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const providerName = args.workoutProvider ?? "Soma";
|
|
653
|
+
const garminWorkout = transformPlannedWorkoutToGarmin(
|
|
654
|
+
plannedWorkout,
|
|
655
|
+
providerName,
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
const client = new GarminClient({ accessToken });
|
|
659
|
+
const created = await client.createWorkout(garminWorkout);
|
|
660
|
+
|
|
661
|
+
if (!created.workoutId) {
|
|
662
|
+
throw new Error("Garmin API did not return a workoutId after creation.");
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
let garminScheduleId: number | null = null;
|
|
666
|
+
|
|
667
|
+
const plannedDate = plannedWorkout.metadata?.planned_date;
|
|
668
|
+
if (plannedDate) {
|
|
669
|
+
const schedule = await client.createSchedule(
|
|
670
|
+
created.workoutId,
|
|
671
|
+
plannedDate,
|
|
672
|
+
);
|
|
673
|
+
garminScheduleId = schedule.scheduleId ?? null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
garminWorkoutId: created.workoutId,
|
|
678
|
+
garminScheduleId,
|
|
679
|
+
};
|
|
680
|
+
},
|
|
681
|
+
});
|
|
682
|
+
|
|
547
683
|
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
|
548
684
|
|
|
549
685
|
interface SyncAllConfig {
|
|
550
686
|
connectionId: string;
|
|
551
687
|
userId: string;
|
|
552
|
-
consumerKey: string;
|
|
553
|
-
consumerSecret: string;
|
|
554
688
|
timeRange: { uploadStartTimeInSeconds: number; uploadEndTimeInSeconds: number };
|
|
555
689
|
}
|
|
556
690
|
|