@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.
Files changed (54) hide show
  1. package/dist/client/index.d.ts +151 -53
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +162 -69
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +130 -17
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/garmin.d.ts +61 -43
  8. package/dist/component/garmin.d.ts.map +1 -1
  9. package/dist/component/garmin.js +208 -122
  10. package/dist/component/garmin.js.map +1 -1
  11. package/dist/component/public.d.ts +363 -0
  12. package/dist/component/public.d.ts.map +1 -1
  13. package/dist/component/public.js +124 -0
  14. package/dist/component/public.js.map +1 -1
  15. package/dist/component/schema.d.ts +7 -9
  16. package/dist/component/schema.d.ts.map +1 -1
  17. package/dist/component/schema.js +9 -10
  18. package/dist/component/schema.js.map +1 -1
  19. package/dist/component/strava.d.ts +0 -1
  20. package/dist/component/strava.d.ts.map +1 -1
  21. package/dist/component/strava.js +0 -1
  22. package/dist/component/strava.js.map +1 -1
  23. package/dist/component/validators/enums.d.ts +1 -1
  24. package/dist/garmin/auth.d.ts +55 -46
  25. package/dist/garmin/auth.d.ts.map +1 -1
  26. package/dist/garmin/auth.js +82 -122
  27. package/dist/garmin/auth.js.map +1 -1
  28. package/dist/garmin/client.d.ts +64 -17
  29. package/dist/garmin/client.d.ts.map +1 -1
  30. package/dist/garmin/client.js +143 -29
  31. package/dist/garmin/client.js.map +1 -1
  32. package/dist/garmin/index.d.ts +3 -3
  33. package/dist/garmin/index.d.ts.map +1 -1
  34. package/dist/garmin/index.js +4 -4
  35. package/dist/garmin/index.js.map +1 -1
  36. package/dist/garmin/plannedWorkout.d.ts +12 -0
  37. package/dist/garmin/plannedWorkout.d.ts.map +1 -0
  38. package/dist/garmin/plannedWorkout.js +267 -0
  39. package/dist/garmin/plannedWorkout.js.map +1 -0
  40. package/dist/garmin/types.d.ts +78 -6
  41. package/dist/garmin/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/client/index.ts +236 -85
  44. package/src/component/_generated/component.ts +155 -17
  45. package/src/component/garmin.ts +258 -124
  46. package/src/component/public.ts +135 -0
  47. package/src/component/schema.ts +9 -10
  48. package/src/component/strava.ts +0 -1
  49. package/src/garmin/auth.test.ts +71 -96
  50. package/src/garmin/auth.ts +129 -193
  51. package/src/garmin/client.ts +197 -51
  52. package/src/garmin/index.ts +13 -14
  53. package/src/garmin/plannedWorkout.ts +333 -0
  54. package/src/garmin/types.ts +149 -7
@@ -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 { getRequestToken, getAccessToken } from "../garmin/auth.js";
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 1.0a flows.
34
- // Bridges Step 1 (getGarminRequestToken) and Step 3 (completeGarminOAuth).
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
- oauthToken: v.string(),
40
- tokenSecret: v.string(),
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: { oauthToken: v.string() },
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
- oauthToken: v.string(),
61
- tokenSecret: v.string(),
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("by_oauthToken", (q) => q.eq("oauthToken", args.oauthToken))
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: { oauthToken: v.string() },
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("by_oauthToken", (q) => q.eq("oauthToken", args.oauthToken))
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 1.0a tokens for a Garmin connection.
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
- tokenSecret: v.string(),
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
- tokenSecret: args.tokenSecret,
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
- tokenSecret: args.tokenSecret,
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
- * Step 1 of OAuth: obtain a request token and return the authorization URL.
192
+ * Generate a Garmin OAuth 2.0 authorization URL with PKCE.
180
193
  *
181
- * If `userId` is provided, the request token and secret are stored in 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 `token`
187
- * and `tokenSecret` itself and pass them to `connectGarmin` manually.
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 getGarminRequestToken = action({
202
+ export const getGarminAuthUrl = action({
190
203
  args: {
191
- consumerKey: v.string(),
192
- consumerSecret: v.string(),
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 result = await getRequestToken({
203
- consumerKey: args.consumerKey,
204
- consumerSecret: args.consumerSecret,
205
- callbackUrl: args.callbackUrl,
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
- oauthToken: result.oauthToken,
212
- tokenSecret: result.oauthTokenSecret,
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
- * Step 3 of OAuth + initial sync.
239
+ * Exchange an authorization code for tokens + initial sync.
227
240
  *
228
- * Exchanges the request token + verifier for permanent access tokens,
229
- * creates/reactivates the Soma connection, stores tokens, and syncs
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
- consumerKey: v.string(),
236
- consumerSecret: v.string(),
237
- token: v.string(),
238
- tokenSecret: v.string(),
239
- verifier: v.string(),
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
- // 1. Exchange request token for permanent access token
256
- const accessTokenResult = await getAccessToken({
257
- consumerKey: args.consumerKey,
258
- consumerSecret: args.consumerSecret,
259
- token: args.token,
260
- tokenSecret: args.tokenSecret,
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
- // 3. Store permanent OAuth tokens
280
+ const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
271
281
  await ctx.runMutation(internalApi.garmin.storeTokens, {
272
282
  connectionId,
273
- accessToken: accessTokenResult.oauthToken,
274
- tokenSecret: accessTokenResult.oauthTokenSecret,
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
- consumerKey: args.consumerKey,
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
- * `oauth_token` and `oauth_verifier` from the redirect. The action looks
319
- * up the pending state (tokenSecret, userId) stored during Step 1,
320
- * exchanges for permanent tokens, creates the connection, syncs data,
321
- * and cleans up the pending entry.
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
- oauthToken: v.string(),
326
- oauthVerifier: v.string(),
327
- consumerKey: v.string(),
328
- consumerSecret: v.string(),
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
- oauthToken: args.oauthToken,
350
+ state: args.state,
347
351
  });
348
352
  if (!pending) {
349
353
  throw new Error(
350
- "No pending Garmin OAuth state found for this token. " +
351
- "The request token may have expired or was already used.",
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
- // 2. Exchange request token for permanent access token
356
- const accessTokenResult = await getAccessToken({
357
- consumerKey: args.consumerKey,
358
- consumerSecret: args.consumerSecret,
359
- token: args.oauthToken,
360
- tokenSecret: pending.tokenSecret,
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
- oauthToken: args.oauthToken,
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
- // 5. Store permanent OAuth tokens
376
+ const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
376
377
  await ctx.runMutation(internalApi.garmin.storeTokens, {
377
378
  connectionId,
378
- accessToken: accessTokenResult.oauthToken,
379
- tokenSecret: accessTokenResult.oauthTokenSecret,
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
- consumerKey: args.consumerKey,
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 types for the specified
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
- consumerKey: v.string(),
429
- consumerSecret: v.string(),
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 || !tokenDoc.tokenSecret) {
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
- // 3. Create client and sync
477
- const client = new GarminClient({
478
- consumerKey: args.consumerKey,
479
- consumerSecret: args.consumerSecret,
480
- accessToken: tokenDoc.accessToken,
481
- tokenSecret: tokenDoc.tokenSecret,
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
- * Deletes stored tokens and sets the connection to inactive.
513
- * Garmin OAuth 1.0a tokens can't be revoked via API, so we just clean up locally.
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
- // 2. Delete stored tokens
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