@nativesquare/soma 0.6.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 (49) 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 +147 -0
  44. package/src/component/_generated/component.ts +142 -0
  45. package/src/component/garmin.ts +118 -0
  46. package/src/component/public.ts +135 -0
  47. package/src/garmin/client.ts +164 -0
  48. package/src/garmin/plannedWorkout.ts +333 -0
  49. package/src/garmin/types.ts +143 -0
@@ -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
  //
@@ -7,13 +7,14 @@
7
7
  import { v } from "convex/values";
8
8
  import { anyApi } from "convex/server";
9
9
  import { action, internalMutation, internalQuery, } from "./_generated/server.js";
10
- import { getRequestToken, getAccessToken } from "../garmin/auth.js";
10
+ import { generateCodeVerifier, generateCodeChallenge, generateState, buildAuthUrl, exchangeCode, refreshToken, } from "../garmin/auth.js";
11
11
  import { GarminClient } from "../garmin/client.js";
12
12
  import { transformActivity } from "../garmin/activity.js";
13
13
  import { transformDaily } from "../garmin/daily.js";
14
14
  import { transformSleep } from "../garmin/sleep.js";
15
15
  import { transformBody } from "../garmin/body.js";
16
16
  import { transformMenstruation } from "../garmin/menstruation.js";
17
+ import { transformPlannedWorkoutToGarmin } from "../garmin/plannedWorkout.js";
17
18
  // Use anyApi to avoid circular type references between this file and _generated/api.ts.
18
19
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
20
  const publicApi = anyApi;
@@ -21,14 +22,16 @@ const publicApi = anyApi;
21
22
  const internalApi = anyApi;
22
23
  // Default sync window: last 30 days
23
24
  const DEFAULT_SYNC_DAYS = 30;
25
+ // Refresh buffer: refresh tokens 10 minutes before expiry
26
+ const REFRESH_BUFFER_SECONDS = 600;
24
27
  // ─── Internal Pending OAuth CRUD ─────────────────────────────────────────────
25
- // Temporary storage for in-progress Garmin OAuth 1.0a flows.
26
- // Bridges Step 1 (getGarminRequestToken) and Step 3 (completeGarminOAuth).
28
+ // Temporary storage for in-progress Garmin OAuth 2.0 PKCE flows.
29
+ // Bridges getGarminAuthUrl and completeGarminOAuth.
27
30
  export const storePendingOAuth = internalMutation({
28
31
  args: {
29
32
  provider: v.string(),
30
- oauthToken: v.string(),
31
- tokenSecret: v.string(),
33
+ state: v.string(),
34
+ codeVerifier: v.string(),
32
35
  userId: v.string(),
33
36
  },
34
37
  returns: v.null(),
@@ -41,30 +44,30 @@ export const storePendingOAuth = internalMutation({
41
44
  },
42
45
  });
43
46
  export const getPendingOAuth = internalQuery({
44
- args: { oauthToken: v.string() },
47
+ args: { state: v.string() },
45
48
  returns: v.union(v.object({
46
49
  _id: v.id("pendingOAuth"),
47
50
  _creationTime: v.number(),
48
51
  provider: v.string(),
49
- oauthToken: v.string(),
50
- tokenSecret: v.string(),
52
+ state: v.string(),
53
+ codeVerifier: v.string(),
51
54
  userId: v.string(),
52
55
  createdAt: v.number(),
53
56
  }), v.null()),
54
57
  handler: async (ctx, args) => {
55
58
  return await ctx.db
56
59
  .query("pendingOAuth")
57
- .withIndex("by_oauthToken", (q) => q.eq("oauthToken", args.oauthToken))
60
+ .withIndex("by_state", (q) => q.eq("state", args.state))
58
61
  .first();
59
62
  },
60
63
  });
61
64
  export const deletePendingOAuth = internalMutation({
62
- args: { oauthToken: v.string() },
65
+ args: { state: v.string() },
63
66
  returns: v.null(),
64
67
  handler: async (ctx, args) => {
65
68
  const pending = await ctx.db
66
69
  .query("pendingOAuth")
67
- .withIndex("by_oauthToken", (q) => q.eq("oauthToken", args.oauthToken))
70
+ .withIndex("by_state", (q) => q.eq("state", args.state))
68
71
  .first();
69
72
  if (pending) {
70
73
  await ctx.db.delete(pending._id);
@@ -74,14 +77,15 @@ export const deletePendingOAuth = internalMutation({
74
77
  });
75
78
  // ─── Internal Token CRUD ─────────────────────────────────────────────────────
76
79
  /**
77
- * Store OAuth 1.0a tokens for a Garmin connection.
80
+ * Store OAuth 2.0 tokens for a Garmin connection.
78
81
  * Upserts by connectionId — one token record per connection.
79
82
  */
80
83
  export const storeTokens = internalMutation({
81
84
  args: {
82
85
  connectionId: v.id("connections"),
83
86
  accessToken: v.string(),
84
- tokenSecret: v.string(),
87
+ refreshToken: v.string(),
88
+ expiresAt: v.number(),
85
89
  },
86
90
  returns: v.null(),
87
91
  handler: async (ctx, args) => {
@@ -92,14 +96,16 @@ export const storeTokens = internalMutation({
92
96
  if (existing) {
93
97
  await ctx.db.patch(existing._id, {
94
98
  accessToken: args.accessToken,
95
- tokenSecret: args.tokenSecret,
99
+ refreshToken: args.refreshToken,
100
+ expiresAt: args.expiresAt,
96
101
  });
97
102
  return null;
98
103
  }
99
104
  await ctx.db.insert("providerTokens", {
100
105
  connectionId: args.connectionId,
101
106
  accessToken: args.accessToken,
102
- tokenSecret: args.tokenSecret,
107
+ refreshToken: args.refreshToken,
108
+ expiresAt: args.expiresAt,
103
109
  });
104
110
  return null;
105
111
  },
@@ -115,7 +121,6 @@ export const getTokens = internalQuery({
115
121
  connectionId: v.id("connections"),
116
122
  accessToken: v.string(),
117
123
  refreshToken: v.optional(v.string()),
118
- tokenSecret: v.optional(v.string()),
119
124
  expiresAt: v.optional(v.number()),
120
125
  }), v.null()),
121
126
  handler: async (ctx, args) => {
@@ -144,64 +149,62 @@ export const deleteTokens = internalMutation({
144
149
  });
145
150
  // ─── Public Actions ──────────────────────────────────────────────────────────
146
151
  /**
147
- * Step 1 of OAuth: obtain a request token and return the authorization URL.
152
+ * Generate a Garmin OAuth 2.0 authorization URL with PKCE.
148
153
  *
149
- * If `userId` is provided, the request token and secret are stored in the
154
+ * If `userId` is provided, the PKCE code verifier and state are stored in the
150
155
  * component's `pendingOAuth` table so that `completeGarminOAuth` can look
151
156
  * them up automatically when the callback fires. This is the recommended
152
157
  * flow when using `registerRoutes`.
153
158
  *
154
- * If `userId` is omitted, the host app must store the returned `token`
155
- * and `tokenSecret` itself and pass them to `connectGarmin` manually.
159
+ * If `userId` is omitted, the host app must store the returned `codeVerifier`
160
+ * itself and pass it to `connectGarmin` manually.
156
161
  */
157
- export const getGarminRequestToken = action({
162
+ export const getGarminAuthUrl = action({
158
163
  args: {
159
- consumerKey: v.string(),
160
- consumerSecret: v.string(),
161
- callbackUrl: v.optional(v.string()),
164
+ clientId: v.string(),
165
+ redirectUri: v.optional(v.string()),
162
166
  userId: v.optional(v.string()),
163
167
  },
164
168
  returns: v.object({
165
- token: v.string(),
166
- tokenSecret: v.string(),
167
169
  authUrl: v.string(),
170
+ state: v.string(),
171
+ codeVerifier: v.string(),
168
172
  }),
169
173
  handler: async (ctx, args) => {
170
- const result = await getRequestToken({
171
- consumerKey: args.consumerKey,
172
- consumerSecret: args.consumerSecret,
173
- callbackUrl: args.callbackUrl,
174
+ const codeVerifier = generateCodeVerifier();
175
+ const codeChallenge = await generateCodeChallenge(codeVerifier);
176
+ const state = generateState();
177
+ const authUrl = buildAuthUrl({
178
+ clientId: args.clientId,
179
+ codeChallenge,
180
+ redirectUri: args.redirectUri,
181
+ state,
174
182
  });
175
183
  if (args.userId) {
176
184
  await ctx.runMutation(internalApi.garmin.storePendingOAuth, {
177
185
  provider: "GARMIN",
178
- oauthToken: result.oauthToken,
179
- tokenSecret: result.oauthTokenSecret,
186
+ state,
187
+ codeVerifier,
180
188
  userId: args.userId,
181
189
  });
182
190
  }
183
- return {
184
- token: result.oauthToken,
185
- tokenSecret: result.oauthTokenSecret,
186
- authUrl: result.authUrl,
187
- };
191
+ return { authUrl, state, codeVerifier };
188
192
  },
189
193
  });
190
194
  /**
191
- * Step 3 of OAuth + initial sync.
195
+ * Exchange an authorization code for tokens + initial sync.
192
196
  *
193
- * Exchanges the request token + verifier for permanent access tokens,
194
- * creates/reactivates the Soma connection, stores tokens, and syncs
195
- * the last 30 days of all data types.
197
+ * Used in the manual flow where the host app stores the code verifier
198
+ * and handles the callback itself.
196
199
  */
197
200
  export const connectGarmin = action({
198
201
  args: {
199
202
  userId: v.string(),
200
- consumerKey: v.string(),
201
- consumerSecret: v.string(),
202
- token: v.string(),
203
- tokenSecret: v.string(),
204
- verifier: v.string(),
203
+ clientId: v.string(),
204
+ clientSecret: v.string(),
205
+ code: v.string(),
206
+ codeVerifier: v.string(),
207
+ redirectUri: v.optional(v.string()),
205
208
  },
206
209
  returns: v.object({
207
210
  connectionId: v.string(),
@@ -215,31 +218,26 @@ export const connectGarmin = action({
215
218
  errors: v.array(v.object({ type: v.string(), id: v.string(), error: v.string() })),
216
219
  }),
217
220
  handler: async (ctx, args) => {
218
- // 1. Exchange request token for permanent access token
219
- const accessTokenResult = await getAccessToken({
220
- consumerKey: args.consumerKey,
221
- consumerSecret: args.consumerSecret,
222
- token: args.token,
223
- tokenSecret: args.tokenSecret,
224
- verifier: args.verifier,
221
+ const tokenResult = await exchangeCode({
222
+ clientId: args.clientId,
223
+ clientSecret: args.clientSecret,
224
+ code: args.code,
225
+ codeVerifier: args.codeVerifier,
226
+ redirectUri: args.redirectUri,
225
227
  });
226
- // 2. Create/reactivate the Soma connection
227
228
  const connectionId = await ctx.runMutation(publicApi.public.connect, {
228
229
  userId: args.userId,
229
230
  provider: "GARMIN",
230
231
  });
231
- // 3. Store permanent OAuth tokens
232
+ const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
232
233
  await ctx.runMutation(internalApi.garmin.storeTokens, {
233
234
  connectionId,
234
- accessToken: accessTokenResult.oauthToken,
235
- tokenSecret: accessTokenResult.oauthTokenSecret,
235
+ accessToken: tokenResult.access_token,
236
+ refreshToken: tokenResult.refresh_token,
237
+ expiresAt,
236
238
  });
237
- // 4. Sync last 30 days of all data types
238
239
  const client = new GarminClient({
239
- consumerKey: args.consumerKey,
240
- consumerSecret: args.consumerSecret,
241
- accessToken: accessTokenResult.oauthToken,
242
- tokenSecret: accessTokenResult.oauthTokenSecret,
240
+ accessToken: tokenResult.access_token,
243
241
  });
244
242
  const now = Math.floor(Date.now() / 1000);
245
243
  const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
@@ -250,11 +248,8 @@ export const connectGarmin = action({
250
248
  const result = await syncAllTypes(ctx, client, {
251
249
  connectionId,
252
250
  userId: args.userId,
253
- consumerKey: args.consumerKey,
254
- consumerSecret: args.consumerSecret,
255
251
  timeRange,
256
252
  });
257
- // 5. Update lastDataUpdate timestamp
258
253
  await ctx.runMutation(publicApi.public.updateConnection, {
259
254
  connectionId,
260
255
  lastDataUpdate: new Date().toISOString(),
@@ -267,20 +262,21 @@ export const connectGarmin = action({
267
262
  },
268
263
  });
269
264
  /**
270
- * Complete a Garmin OAuth flow using stored pending state.
265
+ * Complete a Garmin OAuth 2.0 flow using stored pending state.
271
266
  *
272
267
  * Used by `registerRoutes` — the callback handler calls this with the
273
- * `oauth_token` and `oauth_verifier` from the redirect. The action looks
274
- * up the pending state (tokenSecret, userId) stored during Step 1,
275
- * exchanges for permanent tokens, creates the connection, syncs data,
276
- * and cleans up the pending entry.
268
+ * `code` and `state` from the redirect. The action looks up the pending
269
+ * state (codeVerifier, userId) stored during `getGarminAuthUrl`,
270
+ * exchanges for tokens, creates the connection, syncs data, and
271
+ * cleans up the pending entry.
277
272
  */
278
273
  export const completeGarminOAuth = action({
279
274
  args: {
280
- oauthToken: v.string(),
281
- oauthVerifier: v.string(),
282
- consumerKey: v.string(),
283
- consumerSecret: v.string(),
275
+ code: v.string(),
276
+ state: v.string(),
277
+ clientId: v.string(),
278
+ clientSecret: v.string(),
279
+ redirectUri: v.optional(v.string()),
284
280
  },
285
281
  returns: v.object({
286
282
  connectionId: v.string(),
@@ -294,43 +290,36 @@ export const completeGarminOAuth = action({
294
290
  errors: v.array(v.object({ type: v.string(), id: v.string(), error: v.string() })),
295
291
  }),
296
292
  handler: async (ctx, args) => {
297
- // 1. Look up pending state
298
293
  const pending = await ctx.runQuery(internalApi.garmin.getPendingOAuth, {
299
- oauthToken: args.oauthToken,
294
+ state: args.state,
300
295
  });
301
296
  if (!pending) {
302
- throw new Error("No pending Garmin OAuth state found for this token. " +
303
- "The request token may have expired or was already used.");
297
+ throw new Error("No pending Garmin OAuth state found for this state parameter. " +
298
+ "The authorization may have expired or was already used.");
304
299
  }
305
- // 2. Exchange request token for permanent access token
306
- const accessTokenResult = await getAccessToken({
307
- consumerKey: args.consumerKey,
308
- consumerSecret: args.consumerSecret,
309
- token: args.oauthToken,
310
- tokenSecret: pending.tokenSecret,
311
- verifier: args.oauthVerifier,
300
+ const tokenResult = await exchangeCode({
301
+ clientId: args.clientId,
302
+ clientSecret: args.clientSecret,
303
+ code: args.code,
304
+ codeVerifier: pending.codeVerifier,
305
+ redirectUri: args.redirectUri,
312
306
  });
313
- // 3. Delete pending state (no longer needed)
314
307
  await ctx.runMutation(internalApi.garmin.deletePendingOAuth, {
315
- oauthToken: args.oauthToken,
308
+ state: args.state,
316
309
  });
317
- // 4. Create/reactivate the Soma connection
318
310
  const connectionId = await ctx.runMutation(publicApi.public.connect, {
319
311
  userId: pending.userId,
320
312
  provider: "GARMIN",
321
313
  });
322
- // 5. Store permanent OAuth tokens
314
+ const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
323
315
  await ctx.runMutation(internalApi.garmin.storeTokens, {
324
316
  connectionId,
325
- accessToken: accessTokenResult.oauthToken,
326
- tokenSecret: accessTokenResult.oauthTokenSecret,
317
+ accessToken: tokenResult.access_token,
318
+ refreshToken: tokenResult.refresh_token,
319
+ expiresAt,
327
320
  });
328
- // 6. Sync last 30 days of all data types
329
321
  const client = new GarminClient({
330
- consumerKey: args.consumerKey,
331
- consumerSecret: args.consumerSecret,
332
- accessToken: accessTokenResult.oauthToken,
333
- tokenSecret: accessTokenResult.oauthTokenSecret,
322
+ accessToken: tokenResult.access_token,
334
323
  });
335
324
  const now = Math.floor(Date.now() / 1000);
336
325
  const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
@@ -341,11 +330,8 @@ export const completeGarminOAuth = action({
341
330
  const result = await syncAllTypes(ctx, client, {
342
331
  connectionId,
343
332
  userId: pending.userId,
344
- consumerKey: args.consumerKey,
345
- consumerSecret: args.consumerSecret,
346
333
  timeRange,
347
334
  });
348
- // 7. Update lastDataUpdate timestamp
349
335
  await ctx.runMutation(publicApi.public.updateConnection, {
350
336
  connectionId,
351
337
  lastDataUpdate: new Date().toISOString(),
@@ -360,14 +346,14 @@ export const completeGarminOAuth = action({
360
346
  /**
361
347
  * Incremental Garmin sync for an already-connected user.
362
348
  *
363
- * Looks up the stored tokens and syncs all data types for the specified
364
- * time range (defaults to last 30 days).
349
+ * Looks up the stored tokens, refreshes if expired, and syncs all data
350
+ * types for the specified time range (defaults to last 30 days).
365
351
  */
366
352
  export const syncGarmin = action({
367
353
  args: {
368
354
  userId: v.string(),
369
- consumerKey: v.string(),
370
- consumerSecret: v.string(),
355
+ clientId: v.string(),
356
+ clientSecret: v.string(),
371
357
  startTimeInSeconds: v.optional(v.number()),
372
358
  endTimeInSeconds: v.optional(v.number()),
373
359
  },
@@ -382,7 +368,6 @@ export const syncGarmin = action({
382
368
  errors: v.array(v.object({ type: v.string(), id: v.string(), error: v.string() })),
383
369
  }),
384
370
  handler: async (ctx, args) => {
385
- // 1. Look up connection
386
371
  const connection = await ctx.runQuery(internalApi.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
387
372
  if (!connection) {
388
373
  throw new Error(`No Garmin connection found for user "${args.userId}". ` +
@@ -392,21 +377,34 @@ export const syncGarmin = action({
392
377
  throw new Error(`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`);
393
378
  }
394
379
  const connectionId = connection._id;
395
- // 2. Get stored tokens
396
380
  const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
397
381
  connectionId,
398
382
  });
399
- if (!tokenDoc || !tokenDoc.tokenSecret) {
383
+ if (!tokenDoc) {
400
384
  throw new Error("No Garmin tokens found for this connection. " +
401
385
  "The connection may have been created before token storage was available.");
402
386
  }
403
- // 3. Create client and sync
404
- const client = new GarminClient({
405
- consumerKey: args.consumerKey,
406
- consumerSecret: args.consumerSecret,
407
- accessToken: tokenDoc.accessToken,
408
- tokenSecret: tokenDoc.tokenSecret,
409
- });
387
+ let accessToken = tokenDoc.accessToken;
388
+ // Refresh the token if it's expired or about to expire
389
+ const nowSeconds = Math.floor(Date.now() / 1000);
390
+ if (tokenDoc.expiresAt &&
391
+ tokenDoc.refreshToken &&
392
+ nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS) {
393
+ const refreshed = await refreshToken({
394
+ clientId: args.clientId,
395
+ clientSecret: args.clientSecret,
396
+ refreshToken: tokenDoc.refreshToken,
397
+ });
398
+ accessToken = refreshed.access_token;
399
+ const newExpiresAt = nowSeconds + refreshed.expires_in;
400
+ await ctx.runMutation(internalApi.garmin.storeTokens, {
401
+ connectionId,
402
+ accessToken: refreshed.access_token,
403
+ refreshToken: refreshed.refresh_token,
404
+ expiresAt: newExpiresAt,
405
+ });
406
+ }
407
+ const client = new GarminClient({ accessToken });
410
408
  const now = Math.floor(Date.now() / 1000);
411
409
  const timeRange = {
412
410
  uploadStartTimeInSeconds: args.startTimeInSeconds ?? now - DEFAULT_SYNC_DAYS * 86400,
@@ -415,11 +413,8 @@ export const syncGarmin = action({
415
413
  const result = await syncAllTypes(ctx, client, {
416
414
  connectionId,
417
415
  userId: args.userId,
418
- consumerKey: args.consumerKey,
419
- consumerSecret: args.consumerSecret,
420
416
  timeRange,
421
417
  });
422
- // 4. Update lastDataUpdate timestamp
423
418
  await ctx.runMutation(publicApi.public.updateConnection, {
424
419
  connectionId,
425
420
  lastDataUpdate: new Date().toISOString(),
@@ -430,8 +425,8 @@ export const syncGarmin = action({
430
425
  /**
431
426
  * Disconnect a user from Garmin.
432
427
  *
433
- * Deletes stored tokens and sets the connection to inactive.
434
- * Garmin OAuth 1.0a tokens can't be revoked via API, so we just clean up locally.
428
+ * Deregisters the user via the Garmin API (best-effort), deletes stored
429
+ * tokens, and sets the connection to inactive.
435
430
  */
436
431
  export const disconnectGarmin = action({
437
432
  args: {
@@ -439,15 +434,25 @@ export const disconnectGarmin = action({
439
434
  },
440
435
  returns: v.null(),
441
436
  handler: async (ctx, args) => {
442
- // 1. Look up connection
443
437
  const connection = await ctx.runQuery(internalApi.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
444
438
  if (!connection) {
445
439
  throw new Error(`No Garmin connection found for user "${args.userId}".`);
446
440
  }
447
441
  const connectionId = connection._id;
448
- // 2. Delete stored tokens
442
+ // Best-effort: deregister user at Garmin
443
+ const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
444
+ connectionId,
445
+ });
446
+ if (tokenDoc) {
447
+ try {
448
+ const client = new GarminClient({ accessToken: tokenDoc.accessToken });
449
+ await client.deleteUserRegistration();
450
+ }
451
+ catch {
452
+ // Deregistration is best-effort; proceed with local cleanup
453
+ }
454
+ }
449
455
  await ctx.runMutation(internalApi.garmin.deleteTokens, { connectionId });
450
- // 3. Set connection inactive
451
456
  await ctx.runMutation(publicApi.public.disconnect, {
452
457
  userId: args.userId,
453
458
  provider: "GARMIN",
@@ -455,6 +460,87 @@ export const disconnectGarmin = action({
455
460
  return null;
456
461
  },
457
462
  });
463
+ // ─── Training API ────────────────────────────────────────────────────────────
464
+ /**
465
+ * Push a planned workout from Soma's DB to Garmin Connect.
466
+ *
467
+ * Reads the planned workout document, transforms it to Garmin Training API V2
468
+ * format, creates the workout at Garmin, and optionally schedules it if a
469
+ * `planned_date` is set in the metadata.
470
+ *
471
+ * Returns the Garmin workout ID and schedule ID (if scheduled).
472
+ */
473
+ export const pushPlannedWorkout = action({
474
+ args: {
475
+ userId: v.string(),
476
+ clientId: v.string(),
477
+ clientSecret: v.string(),
478
+ plannedWorkoutId: v.string(),
479
+ workoutProvider: v.optional(v.string()),
480
+ },
481
+ returns: v.object({
482
+ garminWorkoutId: v.number(),
483
+ garminScheduleId: v.union(v.number(), v.null()),
484
+ }),
485
+ handler: async (ctx, args) => {
486
+ const connection = await ctx.runQuery(internalApi.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
487
+ if (!connection) {
488
+ throw new Error(`No Garmin connection found for user "${args.userId}". ` +
489
+ "Call connectGarmin first.");
490
+ }
491
+ if (!connection.active) {
492
+ throw new Error(`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`);
493
+ }
494
+ const connectionId = connection._id;
495
+ const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
496
+ connectionId,
497
+ });
498
+ if (!tokenDoc) {
499
+ throw new Error("No Garmin tokens found for this connection. " +
500
+ "The connection may have been created before token storage was available.");
501
+ }
502
+ let accessToken = tokenDoc.accessToken;
503
+ const nowSeconds = Math.floor(Date.now() / 1000);
504
+ if (tokenDoc.expiresAt &&
505
+ tokenDoc.refreshToken &&
506
+ nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS) {
507
+ const refreshed = await refreshToken({
508
+ clientId: args.clientId,
509
+ clientSecret: args.clientSecret,
510
+ refreshToken: tokenDoc.refreshToken,
511
+ });
512
+ accessToken = refreshed.access_token;
513
+ const newExpiresAt = nowSeconds + refreshed.expires_in;
514
+ await ctx.runMutation(internalApi.garmin.storeTokens, {
515
+ connectionId,
516
+ accessToken: refreshed.access_token,
517
+ refreshToken: refreshed.refresh_token,
518
+ expiresAt: newExpiresAt,
519
+ });
520
+ }
521
+ const plannedWorkout = await ctx.runQuery(publicApi.public.getPlannedWorkout, { plannedWorkoutId: args.plannedWorkoutId });
522
+ if (!plannedWorkout) {
523
+ throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
524
+ }
525
+ const providerName = args.workoutProvider ?? "Soma";
526
+ const garminWorkout = transformPlannedWorkoutToGarmin(plannedWorkout, providerName);
527
+ const client = new GarminClient({ accessToken });
528
+ const created = await client.createWorkout(garminWorkout);
529
+ if (!created.workoutId) {
530
+ throw new Error("Garmin API did not return a workoutId after creation.");
531
+ }
532
+ let garminScheduleId = null;
533
+ const plannedDate = plannedWorkout.metadata?.planned_date;
534
+ if (plannedDate) {
535
+ const schedule = await client.createSchedule(created.workoutId, plannedDate);
536
+ garminScheduleId = schedule.scheduleId ?? null;
537
+ }
538
+ return {
539
+ garminWorkoutId: created.workoutId,
540
+ garminScheduleId,
541
+ };
542
+ },
543
+ });
458
544
  async function syncAllTypes(ctx, client, config) {
459
545
  const { connectionId, userId, timeRange } = config;
460
546
  const synced = { activities: 0, dailies: 0, sleep: 0, body: 0, menstruation: 0 };