@nativesquare/soma 0.12.0 → 0.13.1

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 (70) hide show
  1. package/dist/client/garmin.d.ts +9 -2
  2. package/dist/client/garmin.d.ts.map +1 -1
  3. package/dist/client/garmin.js +152 -1
  4. package/dist/client/garmin.js.map +1 -1
  5. package/dist/client/index.d.ts +5 -6
  6. package/dist/client/index.d.ts.map +1 -1
  7. package/dist/client/index.js +5 -211
  8. package/dist/client/index.js.map +1 -1
  9. package/dist/client/strava.d.ts +14 -7
  10. package/dist/client/strava.d.ts.map +1 -1
  11. package/dist/client/strava.js +67 -1
  12. package/dist/client/strava.js.map +1 -1
  13. package/dist/client/types.d.ts +93 -20
  14. package/dist/client/types.d.ts.map +1 -1
  15. package/dist/component/_generated/component.d.ts +24 -5
  16. package/dist/component/_generated/component.d.ts.map +1 -1
  17. package/dist/component/garmin/private.d.ts +53 -68
  18. package/dist/component/garmin/private.d.ts.map +1 -1
  19. package/dist/component/garmin/private.js +87 -85
  20. package/dist/component/garmin/private.js.map +1 -1
  21. package/dist/component/garmin/public.d.ts +97 -43
  22. package/dist/component/garmin/public.d.ts.map +1 -1
  23. package/dist/component/garmin/public.js +75 -51
  24. package/dist/component/garmin/public.js.map +1 -1
  25. package/dist/component/garmin/webhooks.d.ts +22 -20
  26. package/dist/component/garmin/webhooks.d.ts.map +1 -1
  27. package/dist/component/garmin/webhooks.js +115 -76
  28. package/dist/component/garmin/webhooks.js.map +1 -1
  29. package/dist/component/public.d.ts +15 -15
  30. package/dist/component/schema.d.ts +25 -25
  31. package/dist/component/strava/public.d.ts +12 -8
  32. package/dist/component/strava/public.d.ts.map +1 -1
  33. package/dist/component/strava/public.js +7 -7
  34. package/dist/component/strava/public.js.map +1 -1
  35. package/dist/component/validators/activity.d.ts +4 -4
  36. package/dist/component/validators/athlete.d.ts +6 -0
  37. package/dist/component/validators/athlete.d.ts.map +1 -1
  38. package/dist/component/validators/athlete.js.map +1 -1
  39. package/dist/component/validators/body.d.ts +4 -4
  40. package/dist/component/validators/daily.d.ts +4 -4
  41. package/dist/component/validators/nutrition.d.ts +9 -3
  42. package/dist/component/validators/nutrition.d.ts.map +1 -1
  43. package/dist/component/validators/nutrition.js.map +1 -1
  44. package/dist/component/validators/samples.d.ts +4 -4
  45. package/dist/component/validators/shared.d.ts +13 -4
  46. package/dist/component/validators/shared.d.ts.map +1 -1
  47. package/dist/component/validators/shared.js +7 -0
  48. package/dist/component/validators/shared.js.map +1 -1
  49. package/dist/component/validators/sleep.d.ts +11 -5
  50. package/dist/component/validators/sleep.d.ts.map +1 -1
  51. package/dist/component/validators/sleep.js.map +1 -1
  52. package/dist/validators.d.ts +48 -41
  53. package/dist/validators.d.ts.map +1 -1
  54. package/dist/validators.js +7 -6
  55. package/dist/validators.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/client/garmin.ts +695 -487
  58. package/src/client/index.ts +10 -279
  59. package/src/client/strava.ts +201 -108
  60. package/src/client/types.ts +303 -215
  61. package/src/component/_generated/component.ts +19 -19
  62. package/src/component/garmin/private.ts +1872 -1870
  63. package/src/component/garmin/public.ts +104 -80
  64. package/src/component/garmin/webhooks.ts +122 -81
  65. package/src/component/strava/public.ts +393 -393
  66. package/src/component/validators/athlete.ts +6 -0
  67. package/src/component/validators/nutrition.ts +6 -0
  68. package/src/component/validators/shared.ts +9 -0
  69. package/src/component/validators/sleep.ts +6 -0
  70. package/src/validators.ts +35 -7
@@ -1,487 +1,695 @@
1
- import type { SomaComponent } from "./index.js";
2
- import type { ActionCtx, SomaGarminConfig } from "./types.js";
3
-
4
- export class SomaGarmin {
5
- constructor(
6
- private component: SomaComponent,
7
- private requireConfig: () => SomaGarminConfig,
8
- ) {}
9
-
10
- /**
11
- * Generate a Garmin OAuth 2.0 authorization URL with PKCE.
12
- *
13
- * The state and code verifier are stored inside the component automatically,
14
- * and the callback handler registered by `registerRoutes` completes
15
- * the flow without further host-app intervention.
16
- *
17
- * @param ctx - Action context from the host app
18
- * @param opts.userId - The host app's user identifier
19
- * @param opts.redirectUri - Optional override for the OAuth callback URL
20
- * @returns `{ authUrl, state }`
21
- *
22
- * @example
23
- * ```ts
24
- * const { authUrl } = await soma.garmin.getAuthUrl(ctx, {
25
- * userId: "user_123",
26
- * redirectUri: "https://your-app.convex.site/api/garmin/callback",
27
- * });
28
- * // Redirect user to authUrl — the callback is handled automatically
29
- * ```
30
- */
31
- async getAuthUrl(
32
- ctx: ActionCtx,
33
- opts: { userId: string; redirectUri?: string },
34
- ) {
35
- const config = this.requireConfig();
36
- return await ctx.runAction(this.component.garmin.public.getGarminAuthUrl, {
37
- clientId: config.clientId,
38
- redirectUri: opts.redirectUri,
39
- userId: opts.userId,
40
- });
41
- }
42
-
43
- /**
44
- * Disconnect a user from Garmin.
45
- *
46
- * Deregisters the user at Garmin (best-effort), deletes stored tokens,
47
- * and sets the connection to inactive.
48
- *
49
- * @param ctx - Action context from the host app
50
- * @param args.userId - The host app's user identifier
51
- *
52
- * @example
53
- * ```ts
54
- * await soma.garmin.disconnect(ctx, { userId: "user_123" });
55
- * ```
56
- */
57
- async disconnect(
58
- ctx: ActionCtx,
59
- args: { userId: string },
60
- ) {
61
- return await ctx.runAction(this.component.garmin.public.disconnectGarmin, args);
62
- }
63
-
64
- /**
65
- * Pull activity summaries from Garmin.
66
- *
67
- * Fetches activities, transforms them into the normalized Soma schema,
68
- * and ingests them with automatic deduplication.
69
- *
70
- * @param ctx - Action context from the host app
71
- * @param args.userId - The host app's user identifier
72
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
73
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
74
- */
75
- async pullActivities(
76
- ctx: ActionCtx,
77
- args: {
78
- userId: string;
79
- startTimeInSeconds?: number;
80
- endTimeInSeconds?: number;
81
- },
82
- ) {
83
- const config = this.requireConfig();
84
- return await ctx.runAction(this.component.garmin.public.pullActivities, {
85
- ...args,
86
- clientId: config.clientId,
87
- clientSecret: config.clientSecret,
88
- });
89
- }
90
-
91
- /**
92
- * Pull daily wellness summaries from Garmin.
93
- *
94
- * @param ctx - Action context from the host app
95
- * @param args.userId - The host app's user identifier
96
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
97
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
98
- */
99
- async pullDailies(
100
- ctx: ActionCtx,
101
- args: {
102
- userId: string;
103
- startTimeInSeconds?: number;
104
- endTimeInSeconds?: number;
105
- },
106
- ) {
107
- const config = this.requireConfig();
108
- return await ctx.runAction(this.component.garmin.public.pullDailies, {
109
- ...args,
110
- clientId: config.clientId,
111
- clientSecret: config.clientSecret,
112
- });
113
- }
114
-
115
- /**
116
- * Pull sleep summaries from Garmin.
117
- *
118
- * @param ctx - Action context from the host app
119
- * @param args.userId - The host app's user identifier
120
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
121
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
122
- */
123
- async pullSleep(
124
- ctx: ActionCtx,
125
- args: {
126
- userId: string;
127
- startTimeInSeconds?: number;
128
- endTimeInSeconds?: number;
129
- },
130
- ) {
131
- const config = this.requireConfig();
132
- return await ctx.runAction(this.component.garmin.public.pullSleep, {
133
- ...args,
134
- clientId: config.clientId,
135
- clientSecret: config.clientSecret,
136
- });
137
- }
138
-
139
- /**
140
- * Pull body composition data from Garmin.
141
- *
142
- * @param ctx - Action context from the host app
143
- * @param args.userId - The host app's user identifier
144
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
145
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
146
- */
147
- async pullBody(
148
- ctx: ActionCtx,
149
- args: {
150
- userId: string;
151
- startTimeInSeconds?: number;
152
- endTimeInSeconds?: number;
153
- },
154
- ) {
155
- const config = this.requireConfig();
156
- return await ctx.runAction(this.component.garmin.public.pullBody, {
157
- ...args,
158
- clientId: config.clientId,
159
- clientSecret: config.clientSecret,
160
- });
161
- }
162
-
163
- /**
164
- * Pull menstrual cycle tracking data from Garmin.
165
- *
166
- * @param ctx - Action context from the host app
167
- * @param args.userId - The host app's user identifier
168
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
169
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
170
- */
171
- async pullMenstruation(
172
- ctx: ActionCtx,
173
- args: {
174
- userId: string;
175
- startTimeInSeconds?: number;
176
- endTimeInSeconds?: number;
177
- },
178
- ) {
179
- const config = this.requireConfig();
180
- return await ctx.runAction(this.component.garmin.public.pullMenstruation, {
181
- ...args,
182
- clientId: config.clientId,
183
- clientSecret: config.clientSecret,
184
- });
185
- }
186
-
187
- /**
188
- * Pull blood pressure readings from Garmin.
189
- *
190
- * @param ctx - Action context from the host app
191
- * @param args.userId - The host app's user identifier
192
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
193
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
194
- */
195
- async pullBloodPressures(
196
- ctx: ActionCtx,
197
- args: {
198
- userId: string;
199
- startTimeInSeconds?: number;
200
- endTimeInSeconds?: number;
201
- },
202
- ) {
203
- const config = this.requireConfig();
204
- return await ctx.runAction(this.component.garmin.public.pullBloodPressures, {
205
- ...args,
206
- clientId: config.clientId,
207
- clientSecret: config.clientSecret,
208
- });
209
- }
210
-
211
- /**
212
- * Pull skin temperature data from Garmin.
213
- *
214
- * @param ctx - Action context from the host app
215
- * @param args.userId - The host app's user identifier
216
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
217
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
218
- */
219
- async pullSkinTemperature(
220
- ctx: ActionCtx,
221
- args: {
222
- userId: string;
223
- startTimeInSeconds?: number;
224
- endTimeInSeconds?: number;
225
- },
226
- ) {
227
- const config = this.requireConfig();
228
- return await ctx.runAction(this.component.garmin.public.pullSkinTemperature, {
229
- ...args,
230
- clientId: config.clientId,
231
- clientSecret: config.clientSecret,
232
- });
233
- }
234
-
235
- /**
236
- * Pull user metrics (VO2 max, fitness age, etc.) from Garmin.
237
- *
238
- * @param ctx - Action context from the host app
239
- * @param args.userId - The host app's user identifier
240
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
241
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
242
- */
243
- async pullUserMetrics(
244
- ctx: ActionCtx,
245
- args: {
246
- userId: string;
247
- startTimeInSeconds?: number;
248
- endTimeInSeconds?: number;
249
- },
250
- ) {
251
- const config = this.requireConfig();
252
- return await ctx.runAction(this.component.garmin.public.pullUserMetrics, {
253
- ...args,
254
- clientId: config.clientId,
255
- clientSecret: config.clientSecret,
256
- });
257
- }
258
-
259
- /**
260
- * Pull heart rate variability (HRV) summaries from Garmin.
261
- *
262
- * @param ctx - Action context from the host app
263
- * @param args.userId - The host app's user identifier
264
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
265
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
266
- */
267
- async pullHRV(
268
- ctx: ActionCtx,
269
- args: {
270
- userId: string;
271
- startTimeInSeconds?: number;
272
- endTimeInSeconds?: number;
273
- },
274
- ) {
275
- const config = this.requireConfig();
276
- return await ctx.runAction(this.component.garmin.public.pullHRV, {
277
- ...args,
278
- clientId: config.clientId,
279
- clientSecret: config.clientSecret,
280
- });
281
- }
282
-
283
- /**
284
- * Pull stress detail data from Garmin.
285
- *
286
- * @param ctx - Action context from the host app
287
- * @param args.userId - The host app's user identifier
288
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
289
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
290
- */
291
- async pullStressDetails(
292
- ctx: ActionCtx,
293
- args: {
294
- userId: string;
295
- startTimeInSeconds?: number;
296
- endTimeInSeconds?: number;
297
- },
298
- ) {
299
- const config = this.requireConfig();
300
- return await ctx.runAction(this.component.garmin.public.pullStressDetails, {
301
- ...args,
302
- clientId: config.clientId,
303
- clientSecret: config.clientSecret,
304
- });
305
- }
306
-
307
- /**
308
- * Pull pulse oximetry (SpO2) data from Garmin.
309
- *
310
- * @param ctx - Action context from the host app
311
- * @param args.userId - The host app's user identifier
312
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
313
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
314
- */
315
- async pullPulseOx(
316
- ctx: ActionCtx,
317
- args: {
318
- userId: string;
319
- startTimeInSeconds?: number;
320
- endTimeInSeconds?: number;
321
- },
322
- ) {
323
- const config = this.requireConfig();
324
- return await ctx.runAction(this.component.garmin.public.pullPulseOx, {
325
- ...args,
326
- clientId: config.clientId,
327
- clientSecret: config.clientSecret,
328
- });
329
- }
330
-
331
- /**
332
- * Pull respiration (breathing rate) data from Garmin.
333
- *
334
- * @param ctx - Action context from the host app
335
- * @param args.userId - The host app's user identifier
336
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
337
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
338
- */
339
- async pullRespiration(
340
- ctx: ActionCtx,
341
- args: {
342
- userId: string;
343
- startTimeInSeconds?: number;
344
- endTimeInSeconds?: number;
345
- },
346
- ) {
347
- const config = this.requireConfig();
348
- return await ctx.runAction(this.component.garmin.public.pullRespiration, {
349
- ...args,
350
- clientId: config.clientId,
351
- clientSecret: config.clientSecret,
352
- });
353
- }
354
-
355
- /**
356
- * Pull all supported data types from Garmin in a single call.
357
- *
358
- * Equivalent to calling every individual `pull*` method. Automatically
359
- * refreshes the access token if expired.
360
- *
361
- * @param ctx - Action context from the host app
362
- * @param args.userId - The host app's user identifier
363
- * @param args.startTimeInSeconds - Optional Unix epoch lower bound
364
- * @param args.endTimeInSeconds - Optional Unix epoch upper bound
365
- *
366
- * @example
367
- * ```ts
368
- * await soma.garmin.pullAll(ctx, { userId: "user_123" });
369
- * ```
370
- */
371
- async pullAll(
372
- ctx: ActionCtx,
373
- args: {
374
- userId: string;
375
- startTimeInSeconds?: number;
376
- endTimeInSeconds?: number;
377
- },
378
- ) {
379
- const config = this.requireConfig();
380
- return await ctx.runAction(this.component.garmin.public.pullAll, {
381
- ...args,
382
- clientId: config.clientId,
383
- clientSecret: config.clientSecret,
384
- });
385
- }
386
-
387
- /**
388
- * Push a planned workout to Garmin Connect.
389
- *
390
- * Creates the workout on the user's Garmin account from a planned
391
- * workout stored in Soma.
392
- *
393
- * @param ctx - Action context from the host app
394
- * @param args.userId - The host app's user identifier
395
- * @param args.plannedWorkoutId - The Soma planned workout document ID
396
- * @param args.workoutProvider - Optional provider identifier for the workout source
397
- */
398
- async pushWorkout(
399
- ctx: ActionCtx,
400
- args: {
401
- userId: string;
402
- plannedWorkoutId: string;
403
- workoutProvider?: string;
404
- },
405
- ) {
406
- const config = this.requireConfig();
407
- return await ctx.runAction(this.component.garmin.public.pushWorkout, {
408
- ...args,
409
- clientId: config.clientId,
410
- clientSecret: config.clientSecret,
411
- });
412
- }
413
-
414
- /**
415
- * Schedule a planned workout on the user's Garmin calendar.
416
- *
417
- * The workout must already exist on Garmin (via `pushWorkout`).
418
- *
419
- * @param ctx - Action context from the host app
420
- * @param args.userId - The host app's user identifier
421
- * @param args.plannedWorkoutId - The Soma planned workout document ID
422
- * @param args.date - Optional target date (YYYY-MM-DD); defaults to the workout's planned_date
423
- */
424
- async pushSchedule(
425
- ctx: ActionCtx,
426
- args: {
427
- userId: string;
428
- plannedWorkoutId: string;
429
- date?: string;
430
- },
431
- ) {
432
- const config = this.requireConfig();
433
- return await ctx.runAction(this.component.garmin.public.pushSchedule, {
434
- ...args,
435
- clientId: config.clientId,
436
- clientSecret: config.clientSecret,
437
- });
438
- }
439
-
440
- /**
441
- * Delete a planned workout from Garmin Connect.
442
- *
443
- * Removes the workout from the user's Garmin account.
444
- *
445
- * @param ctx - Action context from the host app
446
- * @param args.userId - The host app's user identifier
447
- * @param args.plannedWorkoutId - The Soma planned workout document ID
448
- */
449
- async deleteWorkout(
450
- ctx: ActionCtx,
451
- args: {
452
- userId: string;
453
- plannedWorkoutId: string;
454
- },
455
- ) {
456
- const config = this.requireConfig();
457
- return await ctx.runAction(this.component.garmin.public.deleteWorkout, {
458
- ...args,
459
- clientId: config.clientId,
460
- clientSecret: config.clientSecret,
461
- });
462
- }
463
-
464
- /**
465
- * Remove a scheduled workout from the user's Garmin calendar.
466
- *
467
- * Unschedules the workout without deleting the workout itself.
468
- *
469
- * @param ctx - Action context from the host app
470
- * @param args.userId - The host app's user identifier
471
- * @param args.plannedWorkoutId - The Soma planned workout document ID
472
- */
473
- async deleteSchedule(
474
- ctx: ActionCtx,
475
- args: {
476
- userId: string;
477
- plannedWorkoutId: string;
478
- },
479
- ) {
480
- const config = this.requireConfig();
481
- return await ctx.runAction(this.component.garmin.public.deleteSchedule, {
482
- ...args,
483
- clientId: config.clientId,
484
- clientSecret: config.clientSecret,
485
- });
486
- }
487
- }
1
+ import type { SomaComponent } from "./index.js";
2
+ import type {
3
+ ActionCtx,
4
+ SomaGarminConfig,
5
+ RegisterRoutesOptions,
6
+ GarminWebhookActionArgs,
7
+ GarminWebhookActionResult,
8
+ GarminWebhookEventName,
9
+ GarminWebhookEvent,
10
+ } from "./types.js";
11
+ import {
12
+ httpActionGeneric,
13
+ type FunctionReference,
14
+ type HttpRouter,
15
+ } from "convex/server";
16
+
17
+ export const GARMIN_OAUTH_CALLBACK_PATH = "/api/garmin/callback";
18
+ export const GARMIN_WEBHOOK_BASE_PATH = "/api/garmin/webhook";
19
+
20
+ export class SomaGarmin {
21
+ constructor(
22
+ private component: SomaComponent,
23
+ private requireConfig: () => SomaGarminConfig,
24
+ ) { }
25
+
26
+ /**
27
+ * Generate a Garmin OAuth 2.0 authorization URL with PKCE.
28
+ *
29
+ * The state and code verifier are stored inside the component automatically,
30
+ * and the callback handler registered by `registerRoutes` completes
31
+ * the flow without further host-app intervention.
32
+ *
33
+ * @param ctx - Action context from the host app
34
+ * @param opts.userId - The host app's user identifier
35
+ * @param opts.redirectUri - Optional override for the OAuth callback URL.
36
+ * If omitted, Garmin uses the default redirect URI configured in your
37
+ * Garmin developer app settings. When provided, it should match the
38
+ * `registerRoutes` callback path (default: `${CONVEX_SITE_URL}/api/garmin/callback`).
39
+ * @returns `{ authUrl, state }`
40
+ *
41
+ * @example
42
+ * ```ts
43
+ * const { authUrl } = await soma.garmin.getAuthUrl(ctx, {
44
+ * userId: "user_123",
45
+ * redirectUri: "https://your-app.convex.site/api/garmin/callback",
46
+ * });
47
+ * // Redirect user to authUrl — the callback is handled automatically
48
+ * ```
49
+ */
50
+ async getAuthUrl(
51
+ ctx: ActionCtx,
52
+ opts: { userId: string; redirectUri?: string },
53
+ ) {
54
+ const config = this.requireConfig();
55
+ return await ctx.runAction(this.component.garmin.public.getGarminAuthUrl, {
56
+ clientId: config.clientId,
57
+ redirectUri: opts.redirectUri,
58
+ userId: opts.userId,
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Disconnect a user from Garmin.
64
+ *
65
+ * Deregisters the user at Garmin (best-effort), deletes stored tokens,
66
+ * and sets the connection to inactive.
67
+ *
68
+ * @param ctx - Action context from the host app
69
+ * @param args.userId - The host app's user identifier
70
+ *
71
+ * @example
72
+ * ```ts
73
+ * await soma.garmin.disconnect(ctx, { userId: "user_123" });
74
+ * ```
75
+ */
76
+ async disconnect(
77
+ ctx: ActionCtx,
78
+ args: { userId: string },
79
+ ) {
80
+ return await ctx.runAction(this.component.garmin.public.disconnectGarmin, args);
81
+ }
82
+
83
+ /**
84
+ * Pull activity summaries from Garmin.
85
+ *
86
+ * Fetches activities, transforms them into the normalized Soma schema,
87
+ * and ingests them with automatic deduplication.
88
+ *
89
+ * @param ctx - Action context from the host app
90
+ * @param args.userId - The host app's user identifier
91
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
92
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
93
+ */
94
+ async pullActivities(
95
+ ctx: ActionCtx,
96
+ args: {
97
+ userId: string;
98
+ startTimeInSeconds?: number;
99
+ endTimeInSeconds?: number;
100
+ },
101
+ ) {
102
+ const config = this.requireConfig();
103
+ return await ctx.runAction(this.component.garmin.public.pullActivities, {
104
+ ...args,
105
+ clientId: config.clientId,
106
+ clientSecret: config.clientSecret,
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Pull daily wellness summaries from Garmin.
112
+ *
113
+ * @param ctx - Action context from the host app
114
+ * @param args.userId - The host app's user identifier
115
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
116
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
117
+ */
118
+ async pullDailies(
119
+ ctx: ActionCtx,
120
+ args: {
121
+ userId: string;
122
+ startTimeInSeconds?: number;
123
+ endTimeInSeconds?: number;
124
+ },
125
+ ) {
126
+ const config = this.requireConfig();
127
+ return await ctx.runAction(this.component.garmin.public.pullDailies, {
128
+ ...args,
129
+ clientId: config.clientId,
130
+ clientSecret: config.clientSecret,
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Pull sleep summaries from Garmin.
136
+ *
137
+ * @param ctx - Action context from the host app
138
+ * @param args.userId - The host app's user identifier
139
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
140
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
141
+ */
142
+ async pullSleep(
143
+ ctx: ActionCtx,
144
+ args: {
145
+ userId: string;
146
+ startTimeInSeconds?: number;
147
+ endTimeInSeconds?: number;
148
+ },
149
+ ) {
150
+ const config = this.requireConfig();
151
+ return await ctx.runAction(this.component.garmin.public.pullSleep, {
152
+ ...args,
153
+ clientId: config.clientId,
154
+ clientSecret: config.clientSecret,
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Pull body composition data from Garmin.
160
+ *
161
+ * @param ctx - Action context from the host app
162
+ * @param args.userId - The host app's user identifier
163
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
164
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
165
+ */
166
+ async pullBody(
167
+ ctx: ActionCtx,
168
+ args: {
169
+ userId: string;
170
+ startTimeInSeconds?: number;
171
+ endTimeInSeconds?: number;
172
+ },
173
+ ) {
174
+ const config = this.requireConfig();
175
+ return await ctx.runAction(this.component.garmin.public.pullBody, {
176
+ ...args,
177
+ clientId: config.clientId,
178
+ clientSecret: config.clientSecret,
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Pull menstrual cycle tracking data from Garmin.
184
+ *
185
+ * @param ctx - Action context from the host app
186
+ * @param args.userId - The host app's user identifier
187
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
188
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
189
+ */
190
+ async pullMenstruation(
191
+ ctx: ActionCtx,
192
+ args: {
193
+ userId: string;
194
+ startTimeInSeconds?: number;
195
+ endTimeInSeconds?: number;
196
+ },
197
+ ) {
198
+ const config = this.requireConfig();
199
+ return await ctx.runAction(this.component.garmin.public.pullMenstruation, {
200
+ ...args,
201
+ clientId: config.clientId,
202
+ clientSecret: config.clientSecret,
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Pull blood pressure readings from Garmin.
208
+ *
209
+ * @param ctx - Action context from the host app
210
+ * @param args.userId - The host app's user identifier
211
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
212
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
213
+ */
214
+ async pullBloodPressures(
215
+ ctx: ActionCtx,
216
+ args: {
217
+ userId: string;
218
+ startTimeInSeconds?: number;
219
+ endTimeInSeconds?: number;
220
+ },
221
+ ) {
222
+ const config = this.requireConfig();
223
+ return await ctx.runAction(this.component.garmin.public.pullBloodPressures, {
224
+ ...args,
225
+ clientId: config.clientId,
226
+ clientSecret: config.clientSecret,
227
+ });
228
+ }
229
+
230
+ /**
231
+ * Pull skin temperature data from Garmin.
232
+ *
233
+ * @param ctx - Action context from the host app
234
+ * @param args.userId - The host app's user identifier
235
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
236
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
237
+ */
238
+ async pullSkinTemperature(
239
+ ctx: ActionCtx,
240
+ args: {
241
+ userId: string;
242
+ startTimeInSeconds?: number;
243
+ endTimeInSeconds?: number;
244
+ },
245
+ ) {
246
+ const config = this.requireConfig();
247
+ return await ctx.runAction(this.component.garmin.public.pullSkinTemperature, {
248
+ ...args,
249
+ clientId: config.clientId,
250
+ clientSecret: config.clientSecret,
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Pull user metrics (VO2 max, fitness age, etc.) from Garmin.
256
+ *
257
+ * @param ctx - Action context from the host app
258
+ * @param args.userId - The host app's user identifier
259
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
260
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
261
+ */
262
+ async pullUserMetrics(
263
+ ctx: ActionCtx,
264
+ args: {
265
+ userId: string;
266
+ startTimeInSeconds?: number;
267
+ endTimeInSeconds?: number;
268
+ },
269
+ ) {
270
+ const config = this.requireConfig();
271
+ return await ctx.runAction(this.component.garmin.public.pullUserMetrics, {
272
+ ...args,
273
+ clientId: config.clientId,
274
+ clientSecret: config.clientSecret,
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Pull heart rate variability (HRV) summaries from Garmin.
280
+ *
281
+ * @param ctx - Action context from the host app
282
+ * @param args.userId - The host app's user identifier
283
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
284
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
285
+ */
286
+ async pullHRV(
287
+ ctx: ActionCtx,
288
+ args: {
289
+ userId: string;
290
+ startTimeInSeconds?: number;
291
+ endTimeInSeconds?: number;
292
+ },
293
+ ) {
294
+ const config = this.requireConfig();
295
+ return await ctx.runAction(this.component.garmin.public.pullHRV, {
296
+ ...args,
297
+ clientId: config.clientId,
298
+ clientSecret: config.clientSecret,
299
+ });
300
+ }
301
+
302
+ /**
303
+ * Pull stress detail data from Garmin.
304
+ *
305
+ * @param ctx - Action context from the host app
306
+ * @param args.userId - The host app's user identifier
307
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
308
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
309
+ */
310
+ async pullStressDetails(
311
+ ctx: ActionCtx,
312
+ args: {
313
+ userId: string;
314
+ startTimeInSeconds?: number;
315
+ endTimeInSeconds?: number;
316
+ },
317
+ ) {
318
+ const config = this.requireConfig();
319
+ return await ctx.runAction(this.component.garmin.public.pullStressDetails, {
320
+ ...args,
321
+ clientId: config.clientId,
322
+ clientSecret: config.clientSecret,
323
+ });
324
+ }
325
+
326
+ /**
327
+ * Pull pulse oximetry (SpO2) data from Garmin.
328
+ *
329
+ * @param ctx - Action context from the host app
330
+ * @param args.userId - The host app's user identifier
331
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
332
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
333
+ */
334
+ async pullPulseOx(
335
+ ctx: ActionCtx,
336
+ args: {
337
+ userId: string;
338
+ startTimeInSeconds?: number;
339
+ endTimeInSeconds?: number;
340
+ },
341
+ ) {
342
+ const config = this.requireConfig();
343
+ return await ctx.runAction(this.component.garmin.public.pullPulseOx, {
344
+ ...args,
345
+ clientId: config.clientId,
346
+ clientSecret: config.clientSecret,
347
+ });
348
+ }
349
+
350
+ /**
351
+ * Pull respiration (breathing rate) data from Garmin.
352
+ *
353
+ * @param ctx - Action context from the host app
354
+ * @param args.userId - The host app's user identifier
355
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
356
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
357
+ */
358
+ async pullRespiration(
359
+ ctx: ActionCtx,
360
+ args: {
361
+ userId: string;
362
+ startTimeInSeconds?: number;
363
+ endTimeInSeconds?: number;
364
+ },
365
+ ) {
366
+ const config = this.requireConfig();
367
+ return await ctx.runAction(this.component.garmin.public.pullRespiration, {
368
+ ...args,
369
+ clientId: config.clientId,
370
+ clientSecret: config.clientSecret,
371
+ });
372
+ }
373
+
374
+ /**
375
+ * Pull all supported data types from Garmin in a single call.
376
+ *
377
+ * Equivalent to calling every individual `pull*` method. Automatically
378
+ * refreshes the access token if expired.
379
+ *
380
+ * @param ctx - Action context from the host app
381
+ * @param args.userId - The host app's user identifier
382
+ * @param args.startTimeInSeconds - Optional Unix epoch lower bound
383
+ * @param args.endTimeInSeconds - Optional Unix epoch upper bound
384
+ *
385
+ * @example
386
+ * ```ts
387
+ * await soma.garmin.pullAll(ctx, { userId: "user_123" });
388
+ * ```
389
+ */
390
+ async pullAll(
391
+ ctx: ActionCtx,
392
+ args: {
393
+ userId: string;
394
+ startTimeInSeconds?: number;
395
+ endTimeInSeconds?: number;
396
+ },
397
+ ) {
398
+ const config = this.requireConfig();
399
+ return await ctx.runAction(this.component.garmin.public.pullAll, {
400
+ ...args,
401
+ clientId: config.clientId,
402
+ clientSecret: config.clientSecret,
403
+ });
404
+ }
405
+
406
+ /**
407
+ * Push a planned workout to Garmin Connect.
408
+ *
409
+ * Creates the workout on the user's Garmin account from a planned
410
+ * workout stored in Soma.
411
+ *
412
+ * @param ctx - Action context from the host app
413
+ * @param args.userId - The host app's user identifier
414
+ * @param args.plannedWorkoutId - The Soma planned workout document ID
415
+ * @param args.workoutProvider - Optional provider identifier for the workout source
416
+ */
417
+ async pushWorkout(
418
+ ctx: ActionCtx,
419
+ args: {
420
+ userId: string;
421
+ plannedWorkoutId: string;
422
+ workoutProvider?: string;
423
+ },
424
+ ) {
425
+ const config = this.requireConfig();
426
+ return await ctx.runAction(this.component.garmin.public.pushWorkout, {
427
+ ...args,
428
+ clientId: config.clientId,
429
+ clientSecret: config.clientSecret,
430
+ });
431
+ }
432
+
433
+ /**
434
+ * Schedule a planned workout on the user's Garmin calendar.
435
+ *
436
+ * The workout must already exist on Garmin (via `pushWorkout`).
437
+ *
438
+ * @param ctx - Action context from the host app
439
+ * @param args.userId - The host app's user identifier
440
+ * @param args.plannedWorkoutId - The Soma planned workout document ID
441
+ * @param args.date - Optional target date (YYYY-MM-DD); defaults to the workout's planned_date
442
+ */
443
+ async pushSchedule(
444
+ ctx: ActionCtx,
445
+ args: {
446
+ userId: string;
447
+ plannedWorkoutId: string;
448
+ date?: string;
449
+ },
450
+ ) {
451
+ const config = this.requireConfig();
452
+ return await ctx.runAction(this.component.garmin.public.pushSchedule, {
453
+ ...args,
454
+ clientId: config.clientId,
455
+ clientSecret: config.clientSecret,
456
+ });
457
+ }
458
+
459
+ /**
460
+ * Delete a planned workout from Garmin Connect.
461
+ *
462
+ * Removes the workout from the user's Garmin account.
463
+ *
464
+ * @param ctx - Action context from the host app
465
+ * @param args.userId - The host app's user identifier
466
+ * @param args.plannedWorkoutId - The Soma planned workout document ID
467
+ */
468
+ async deleteWorkout(
469
+ ctx: ActionCtx,
470
+ args: {
471
+ userId: string;
472
+ plannedWorkoutId: string;
473
+ },
474
+ ) {
475
+ const config = this.requireConfig();
476
+ return await ctx.runAction(this.component.garmin.public.deleteWorkout, {
477
+ ...args,
478
+ clientId: config.clientId,
479
+ clientSecret: config.clientSecret,
480
+ });
481
+ }
482
+
483
+ /**
484
+ * Remove a scheduled workout from the user's Garmin calendar.
485
+ *
486
+ * Unschedules the workout without deleting the workout itself.
487
+ *
488
+ * @param ctx - Action context from the host app
489
+ * @param args.userId - The host app's user identifier
490
+ * @param args.plannedWorkoutId - The Soma planned workout document ID
491
+ */
492
+ async deleteSchedule(
493
+ ctx: ActionCtx,
494
+ args: {
495
+ userId: string;
496
+ plannedWorkoutId: string;
497
+ },
498
+ ) {
499
+ const config = this.requireConfig();
500
+ return await ctx.runAction(this.component.garmin.public.deleteSchedule, {
501
+ ...args,
502
+ clientId: config.clientId,
503
+ clientSecret: config.clientSecret,
504
+ });
505
+ }
506
+
507
+ static registerRoutes(
508
+ http: HttpRouter,
509
+ component: SomaComponent,
510
+ opts?: RegisterRoutesOptions["garmin"],
511
+ ) {
512
+ const oauth = opts?.oauth ?? {};
513
+ const path = oauth.path ?? GARMIN_OAUTH_CALLBACK_PATH;
514
+
515
+ http.route({
516
+ path,
517
+ method: "GET",
518
+ handler: httpActionGeneric(async (ctx, request) => {
519
+ const url = new URL(request.url);
520
+ const code = url.searchParams.get("code");
521
+ const state = url.searchParams.get("state");
522
+
523
+ if (!code) {
524
+ return new Response("Missing authorization code", {
525
+ status: 400,
526
+ });
527
+ }
528
+ if (!state) {
529
+ return new Response(
530
+ "Missing state parameter. Ensure the state was included " +
531
+ "when building the Garmin auth URL.",
532
+ { status: 400 },
533
+ );
534
+ }
535
+
536
+ const clientId =
537
+ opts?.clientId ?? process.env.GARMIN_CLIENT_ID;
538
+ const clientSecret =
539
+ opts?.clientSecret ?? process.env.GARMIN_CLIENT_SECRET;
540
+
541
+ if (!clientId || !clientSecret) {
542
+ return new Response(
543
+ "Garmin credentials not configured. Set GARMIN_CLIENT_ID and " +
544
+ "GARMIN_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
545
+ { status: 500 },
546
+ );
547
+ }
548
+
549
+ let result: {
550
+ connectionId: string;
551
+ userId: string;
552
+ };
553
+ try {
554
+ result = await ctx.runAction(component.garmin.public.completeGarminOAuth, {
555
+ code,
556
+ state,
557
+ clientId,
558
+ clientSecret,
559
+ });
560
+ } catch (error) {
561
+ const message =
562
+ error instanceof Error ? error.message : "Unknown error";
563
+ return new Response(`Garmin OAuth callback failed: ${message}`, {
564
+ status: 500,
565
+ });
566
+ }
567
+
568
+ if (oauth.onComplete) {
569
+ try {
570
+ await oauth.onComplete(ctx, {
571
+ provider: "GARMIN",
572
+ userId: result.userId,
573
+ connectionId: result.connectionId,
574
+ });
575
+ } catch (callbackError) {
576
+ console.error(
577
+ "[soma] garmin oauth.onComplete callback error:",
578
+ callbackError instanceof Error ? callbackError.message : callbackError,
579
+ );
580
+ }
581
+ }
582
+
583
+ if (oauth.redirectTo) {
584
+ return new Response(null, {
585
+ status: 302,
586
+ headers: { Location: oauth.redirectTo },
587
+ });
588
+ }
589
+
590
+ return new Response("Successfully connected to Garmin!", {
591
+ status: 200,
592
+ });
593
+ }),
594
+ });
595
+
596
+ // ── Garmin Webhook Routes ──────────────────────────────────
597
+ const webhookCfg = typeof opts?.webhook === "object" ? opts.webhook : undefined;
598
+ if (webhookCfg?.events) {
599
+ const webhookBase = webhookCfg.basePath ?? GARMIN_WEBHOOK_BASE_PATH;
600
+
601
+ const autoIngest = webhookCfg.autoIngest ?? true;
602
+
603
+ const webhookRoutes: Array<{
604
+ dataType: GarminWebhookEventName;
605
+ action: FunctionReference<"action", "internal", GarminWebhookActionArgs, GarminWebhookActionResult>;
606
+ }> = [
607
+ // ACTIVITY category
608
+ { dataType: "activities", action: component.garmin.webhooks.handleGarminWebhookActivities },
609
+ { dataType: "activity-details", action: component.garmin.webhooks.handleGarminWebhookActivityDetails },
610
+ { dataType: "manually-updated-activities", action: component.garmin.webhooks.handleGarminWebhookManuallyUpdatedActivities },
611
+ { dataType: "move-iq", action: component.garmin.webhooks.handleGarminWebhookMoveIQ },
612
+ // HEALTH category
613
+ { dataType: "blood-pressures", action: component.garmin.webhooks.handleGarminWebhookBloodPressures },
614
+ { dataType: "body-compositions", action: component.garmin.webhooks.handleGarminWebhookBodyCompositions },
615
+ { dataType: "dailies", action: component.garmin.webhooks.handleGarminWebhookDailies },
616
+ { dataType: "epochs", action: component.garmin.webhooks.handleGarminWebhookEpochs },
617
+ { dataType: "health-snapshot", action: component.garmin.webhooks.handleGarminWebhookHealthSnapshot },
618
+ { dataType: "sleeps", action: component.garmin.webhooks.handleGarminWebhookSleeps },
619
+ { dataType: "hrv", action: component.garmin.webhooks.handleGarminWebhookHRVSummary },
620
+ { dataType: "stress", action: component.garmin.webhooks.handleGarminWebhookStress },
621
+ { dataType: "pulse-ox", action: component.garmin.webhooks.handleGarminWebhookPulseOx },
622
+ { dataType: "respiration", action: component.garmin.webhooks.handleGarminWebhookRespiration },
623
+ { dataType: "skin-temp", action: component.garmin.webhooks.handleGarminWebhookSkinTemp },
624
+ { dataType: "user-metrics", action: component.garmin.webhooks.handleGarminWebhookUserMetrics },
625
+ // WOMEN_HEALTH category
626
+ { dataType: "menstrual-cycle-tracking", action: component.garmin.webhooks.handleGarminWebhookMenstrualCycleTracking },
627
+ ];
628
+
629
+ for (const route of webhookRoutes) {
630
+ const { dataType } = route;
631
+
632
+ // Only register routes the host app explicitly opted into
633
+ if (!webhookCfg.events?.[dataType]) continue;
634
+
635
+ http.route({
636
+ path: `${webhookBase}/${dataType}`,
637
+ method: "POST",
638
+ handler: httpActionGeneric(async (ctx, request) => {
639
+ let payload: unknown;
640
+ try {
641
+ payload = await request.json();
642
+ } catch {
643
+ return new Response("Invalid JSON body", { status: 400 });
644
+ }
645
+
646
+ let result: GarminWebhookActionResult | undefined;
647
+ try {
648
+ result = await ctx.runAction(route.action, { payload, autoIngest });
649
+ } catch (error) {
650
+ // Log but return 200 to prevent Garmin from retrying
651
+ console.error(
652
+ `Garmin webhook error (${dataType}):`,
653
+ error instanceof Error ? error.message : error,
654
+ );
655
+ }
656
+
657
+ if (result) {
658
+ const event: GarminWebhookEvent = {
659
+ dataType,
660
+ errors: result.errors,
661
+ rawPayload: payload,
662
+ items: result.items,
663
+ };
664
+
665
+ const specificHandler = webhookCfg.events?.[dataType];
666
+ if (typeof specificHandler === "function") {
667
+ try {
668
+ await specificHandler(ctx, event);
669
+ } catch (callbackError) {
670
+ console.error(
671
+ `[soma] garmin webhook events["${dataType}"] callback error:`,
672
+ callbackError instanceof Error ? callbackError.message : callbackError,
673
+ );
674
+ }
675
+ }
676
+
677
+ if (webhookCfg.onEvent) {
678
+ try {
679
+ await webhookCfg.onEvent(ctx, event);
680
+ } catch (callbackError) {
681
+ console.error(
682
+ `[soma] garmin webhook onEvent callback error:`,
683
+ callbackError instanceof Error ? callbackError.message : callbackError,
684
+ );
685
+ }
686
+ }
687
+ }
688
+
689
+ return new Response("OK", { status: 200 });
690
+ }),
691
+ });
692
+ }
693
+ }
694
+ }
695
+ }