@nativesquare/soma 0.10.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/garmin.d.ts +287 -0
- package/dist/client/garmin.d.ts.map +1 -0
- package/dist/client/garmin.js +345 -0
- package/dist/client/garmin.js.map +1 -0
- package/dist/client/index.d.ts +27 -467
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +33 -385
- package/dist/client/index.js.map +1 -1
- package/dist/client/strava.d.ts +92 -0
- package/dist/client/strava.d.ts.map +1 -0
- package/dist/client/strava.js +96 -0
- package/dist/client/strava.js.map +1 -0
- package/dist/client/types.d.ts +165 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +17 -12
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin/public.d.ts +18 -84
- package/dist/component/garmin/public.d.ts.map +1 -1
- package/dist/component/garmin/public.js +147 -539
- package/dist/component/garmin/public.js.map +1 -1
- package/package.json +1 -1
- package/src/client/garmin.ts +487 -0
- package/src/client/index.ts +69 -711
- package/src/client/strava.ts +108 -0
- package/src/client/types.ts +215 -18
- package/src/component/_generated/component.ts +29 -18
- package/src/component/garmin/public.ts +1049 -1406
- package/src/component/garmin/webhooks.ts +857 -857
|
@@ -1,1406 +1,1049 @@
|
|
|
1
|
-
// ─── Garmin Public Actions ───────────────────────────────────────────────────
|
|
2
|
-
// Public actions that handle the full Garmin OAuth 2.0 PKCE + sync lifecycle.
|
|
3
|
-
// The host app calls these through the Soma class, which threads the
|
|
4
|
-
// credentials automatically from env vars or constructor config.
|
|
5
|
-
|
|
6
|
-
import { v } from "convex/values";
|
|
7
|
-
import { action } from "../_generated/server";
|
|
8
|
-
import type { Doc, Id } from "../_generated/dataModel";
|
|
9
|
-
import { generateState } from "../utils.js";
|
|
10
|
-
import {
|
|
11
|
-
generateCodeVerifier,
|
|
12
|
-
generateCodeChallenge,
|
|
13
|
-
buildAuthUrl,
|
|
14
|
-
exchangeCode,
|
|
15
|
-
refreshToken,
|
|
16
|
-
} from "./auth.js";
|
|
17
|
-
import {
|
|
18
|
-
createWellnessClient,
|
|
19
|
-
createTrainingClient,
|
|
20
|
-
} from "./client.js";
|
|
21
|
-
import { buildTimeRangeQuery
|
|
22
|
-
import {
|
|
23
|
-
createWorkoutV2 as sdkCreateWorkoutV2,
|
|
24
|
-
createWorkoutSchedule as sdkCreateWorkoutSchedule,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
50
|
-
import {
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
55
|
-
import {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
if (!
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
};
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
type: "body",
|
|
1051
|
-
id: "fetch",
|
|
1052
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1053
|
-
});
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
// ── Menstruation ────────────────────────────────────────────────────────
|
|
1057
|
-
try {
|
|
1058
|
-
const { data: records, error } = await getMct({ client: wellnessClient, query });
|
|
1059
|
-
if (error || !records) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1060
|
-
for (const record of records) {
|
|
1061
|
-
try {
|
|
1062
|
-
const data = transformMenstrualCycleTracking(record);
|
|
1063
|
-
await ctx.runMutation(api.public.ingestMenstruation, {
|
|
1064
|
-
connectionId,
|
|
1065
|
-
userId,
|
|
1066
|
-
...data,
|
|
1067
|
-
} as never);
|
|
1068
|
-
synced.menstruation++;
|
|
1069
|
-
} catch (err) {
|
|
1070
|
-
errors.push({
|
|
1071
|
-
type: "menstruation",
|
|
1072
|
-
id: record.summaryId ?? record.periodStartDate ?? "unknown",
|
|
1073
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
} catch (err) {
|
|
1078
|
-
errors.push({
|
|
1079
|
-
type: "menstruation",
|
|
1080
|
-
id: "fetch",
|
|
1081
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1082
|
-
});
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
// ── Blood Pressures (→ body) ───────────────────────────────────────────
|
|
1086
|
-
try {
|
|
1087
|
-
const { data: bpRecords, error } = await getBloodPressures({ client: wellnessClient, query });
|
|
1088
|
-
if (error || !bpRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1089
|
-
for (const bp of bpRecords) {
|
|
1090
|
-
try {
|
|
1091
|
-
const data = transformBloodPressure(bp);
|
|
1092
|
-
if (!data) continue;
|
|
1093
|
-
await ctx.runMutation(api.public.ingestBody, {
|
|
1094
|
-
connectionId, userId, ...data,
|
|
1095
|
-
} as never);
|
|
1096
|
-
synced.bloodPressures++;
|
|
1097
|
-
} catch (err) {
|
|
1098
|
-
errors.push({
|
|
1099
|
-
type: "bloodPressure",
|
|
1100
|
-
id: bp.summaryId ?? String(bp.measurementTimeInSeconds),
|
|
1101
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1102
|
-
});
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
} catch (err) {
|
|
1106
|
-
errors.push({ type: "bloodPressure", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
// ── Skin Temperature (→ body) ──────────────────────────────────────────
|
|
1110
|
-
try {
|
|
1111
|
-
const { data: skinRecords, error } = await getSkinTemp({ client: wellnessClient, query });
|
|
1112
|
-
if (error || !skinRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1113
|
-
for (const skin of skinRecords) {
|
|
1114
|
-
try {
|
|
1115
|
-
const data = transformSkinTemperature(skin);
|
|
1116
|
-
if (!data) continue;
|
|
1117
|
-
await ctx.runMutation(api.public.ingestBody, {
|
|
1118
|
-
connectionId, userId, ...data,
|
|
1119
|
-
} as never);
|
|
1120
|
-
synced.skinTemp++;
|
|
1121
|
-
} catch (err) {
|
|
1122
|
-
errors.push({
|
|
1123
|
-
type: "skinTemp",
|
|
1124
|
-
id: skin.summaryId ?? skin.calendarDate ?? "unknown",
|
|
1125
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
} catch (err) {
|
|
1130
|
-
errors.push({ type: "skinTemp", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
// ── User Metrics (→ body) ──────────────────────────────────────────────
|
|
1134
|
-
try {
|
|
1135
|
-
const { data: metricsRecords, error } = await getUserMetrics({ client: wellnessClient, query });
|
|
1136
|
-
if (error || !metricsRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1137
|
-
for (const metrics of metricsRecords) {
|
|
1138
|
-
try {
|
|
1139
|
-
const data = transformUserMetrics(metrics);
|
|
1140
|
-
if (!data) continue;
|
|
1141
|
-
await ctx.runMutation(api.public.ingestBody, {
|
|
1142
|
-
connectionId, userId, ...data,
|
|
1143
|
-
} as never);
|
|
1144
|
-
synced.userMetrics++;
|
|
1145
|
-
} catch (err) {
|
|
1146
|
-
errors.push({
|
|
1147
|
-
type: "userMetrics",
|
|
1148
|
-
id: metrics.summaryId ?? metrics.calendarDate ?? "unknown",
|
|
1149
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1150
|
-
});
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
} catch (err) {
|
|
1154
|
-
errors.push({ type: "userMetrics", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// ── HRV (enriches daily) ──────────────────────────────────────────────
|
|
1158
|
-
try {
|
|
1159
|
-
const { data: hrvRecords, error } = await getHrv({ client: wellnessClient, query });
|
|
1160
|
-
if (error || !hrvRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1161
|
-
for (const hrv of hrvRecords) {
|
|
1162
|
-
try {
|
|
1163
|
-
const data = transformHRVSummary(hrv);
|
|
1164
|
-
if (data) {
|
|
1165
|
-
await ctx.runMutation(api.public.ingestDaily, {
|
|
1166
|
-
connectionId, userId, ...data,
|
|
1167
|
-
} as never);
|
|
1168
|
-
synced.hrv++;
|
|
1169
|
-
}
|
|
1170
|
-
} catch (err) {
|
|
1171
|
-
errors.push({
|
|
1172
|
-
type: "hrv",
|
|
1173
|
-
id: hrv.summaryId ?? hrv.calendarDate ?? "unknown",
|
|
1174
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1175
|
-
});
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
} catch (err) {
|
|
1179
|
-
errors.push({ type: "hrv", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
// ── Stress Details (enriches daily) ────────────────────────────────────
|
|
1183
|
-
try {
|
|
1184
|
-
const { data: stressRecords, error } = await getStressDetails({ client: wellnessClient, query });
|
|
1185
|
-
if (error || !stressRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1186
|
-
for (const stress of stressRecords) {
|
|
1187
|
-
try {
|
|
1188
|
-
const data = transformStress(stress);
|
|
1189
|
-
if (data) {
|
|
1190
|
-
await ctx.runMutation(api.public.ingestDaily, {
|
|
1191
|
-
connectionId, userId, ...data,
|
|
1192
|
-
} as never);
|
|
1193
|
-
synced.stressDetails++;
|
|
1194
|
-
}
|
|
1195
|
-
} catch (err) {
|
|
1196
|
-
errors.push({
|
|
1197
|
-
type: "stressDetails",
|
|
1198
|
-
id: stress.summaryId ?? stress.calendarDate ?? "unknown",
|
|
1199
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1200
|
-
});
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
} catch (err) {
|
|
1204
|
-
errors.push({ type: "stressDetails", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
// ── Pulse Ox (enriches daily) ──────────────────────────────────────────
|
|
1208
|
-
try {
|
|
1209
|
-
const { data: pulseOxRecords, error } = await getPulseox({ client: wellnessClient, query });
|
|
1210
|
-
if (error || !pulseOxRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1211
|
-
for (const po of pulseOxRecords) {
|
|
1212
|
-
try {
|
|
1213
|
-
const data = transformPulseOx(po);
|
|
1214
|
-
if (data) {
|
|
1215
|
-
await ctx.runMutation(api.public.ingestDaily, {
|
|
1216
|
-
connectionId, userId, ...data,
|
|
1217
|
-
} as never);
|
|
1218
|
-
synced.pulseOx++;
|
|
1219
|
-
}
|
|
1220
|
-
} catch (err) {
|
|
1221
|
-
errors.push({
|
|
1222
|
-
type: "pulseOx",
|
|
1223
|
-
id: po.summaryId ?? po.calendarDate ?? "unknown",
|
|
1224
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1225
|
-
});
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
} catch (err) {
|
|
1229
|
-
errors.push({ type: "pulseOx", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
// ── Respiration (enriches daily) ───────────────────────────────────────
|
|
1233
|
-
try {
|
|
1234
|
-
const { data: respRecords, error } = await getRespiration({ client: wellnessClient, query });
|
|
1235
|
-
if (error || !respRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
1236
|
-
for (const resp of respRecords) {
|
|
1237
|
-
try {
|
|
1238
|
-
const data = transformRespiration(resp);
|
|
1239
|
-
if (data) {
|
|
1240
|
-
await ctx.runMutation(api.public.ingestDaily, {
|
|
1241
|
-
connectionId, userId, ...data,
|
|
1242
|
-
} as never);
|
|
1243
|
-
synced.respiration++;
|
|
1244
|
-
}
|
|
1245
|
-
} catch (err) {
|
|
1246
|
-
errors.push({
|
|
1247
|
-
type: "respiration",
|
|
1248
|
-
id: resp.summaryId ?? "unknown",
|
|
1249
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1250
|
-
});
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
} catch (err) {
|
|
1254
|
-
errors.push({ type: "respiration", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
return { synced, errors };
|
|
1258
|
-
},
|
|
1259
|
-
});
|
|
1260
|
-
|
|
1261
|
-
// ─── Push ───────────────────────────────────────────────────────────────────
|
|
1262
|
-
|
|
1263
|
-
/**
|
|
1264
|
-
* Push a planned workout from Soma's DB to Garmin Connect.
|
|
1265
|
-
*
|
|
1266
|
-
* Reads the planned workout document, transforms it to Garmin Training API V2
|
|
1267
|
-
* format, creates the workout at Garmin, and optionally schedules it if a
|
|
1268
|
-
* `planned_date` is set in the metadata.
|
|
1269
|
-
*
|
|
1270
|
-
* Returns the Garmin workout ID and schedule ID (if scheduled).
|
|
1271
|
-
*/
|
|
1272
|
-
export const pushPlannedWorkout = action({
|
|
1273
|
-
args: {
|
|
1274
|
-
userId: v.string(),
|
|
1275
|
-
clientId: v.string(),
|
|
1276
|
-
clientSecret: v.string(),
|
|
1277
|
-
plannedWorkoutId: v.string(),
|
|
1278
|
-
workoutProvider: v.optional(v.string()),
|
|
1279
|
-
},
|
|
1280
|
-
handler: async (ctx, args): Promise<{ garminWorkoutId: number; garminScheduleId: number | null }> => {
|
|
1281
|
-
const connection: Doc<"connections"> | null = await ctx.runQuery(
|
|
1282
|
-
internal.private.getConnectionByProvider,
|
|
1283
|
-
{ userId: args.userId, provider: "GARMIN" },
|
|
1284
|
-
);
|
|
1285
|
-
if (!connection) {
|
|
1286
|
-
throw new Error(
|
|
1287
|
-
`No Garmin connection found for user "${args.userId}". ` +
|
|
1288
|
-
"Connect to Garmin first via getGarminAuthUrl.",
|
|
1289
|
-
);
|
|
1290
|
-
}
|
|
1291
|
-
if (!connection.active) {
|
|
1292
|
-
throw new Error(
|
|
1293
|
-
`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
|
|
1294
|
-
);
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
const connectionId = connection._id;
|
|
1298
|
-
|
|
1299
|
-
const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(internal.garmin.private.getTokens, {
|
|
1300
|
-
connectionId,
|
|
1301
|
-
});
|
|
1302
|
-
if (!tokenDoc) {
|
|
1303
|
-
throw new Error(
|
|
1304
|
-
"No Garmin tokens found for this connection. " +
|
|
1305
|
-
"The connection may have been created before token storage was available.",
|
|
1306
|
-
);
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
// Always force-refresh the token for Training API calls to rule out
|
|
1310
|
-
// stale tokens (the initial sync swallows 401 errors silently).
|
|
1311
|
-
let accessToken = tokenDoc.accessToken;
|
|
1312
|
-
|
|
1313
|
-
if (tokenDoc.refreshToken) {
|
|
1314
|
-
try {
|
|
1315
|
-
const refreshed = await refreshToken({
|
|
1316
|
-
clientId: args.clientId,
|
|
1317
|
-
clientSecret: args.clientSecret,
|
|
1318
|
-
refreshToken: tokenDoc.refreshToken,
|
|
1319
|
-
});
|
|
1320
|
-
|
|
1321
|
-
accessToken = refreshed.access_token;
|
|
1322
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
1323
|
-
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
1324
|
-
const _refreshed: null = await ctx.runMutation(internal.garmin.private.storeTokens, {
|
|
1325
|
-
connectionId,
|
|
1326
|
-
accessToken: refreshed.access_token,
|
|
1327
|
-
refreshToken: refreshed.refresh_token,
|
|
1328
|
-
expiresAt: newExpiresAt,
|
|
1329
|
-
});
|
|
1330
|
-
} catch (refreshErr) {
|
|
1331
|
-
throw new Error(
|
|
1332
|
-
`Garmin token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}. ` +
|
|
1333
|
-
"The user may need to reconnect their Garmin account.",
|
|
1334
|
-
);
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
const plannedWorkout: Doc<"plannedWorkouts"> | null = await ctx.runQuery(
|
|
1339
|
-
api.public.getPlannedWorkout,
|
|
1340
|
-
{ plannedWorkoutId: args.plannedWorkoutId as never },
|
|
1341
|
-
);
|
|
1342
|
-
if (!plannedWorkout) {
|
|
1343
|
-
throw new Error(
|
|
1344
|
-
`Planned workout "${args.plannedWorkoutId}" not found.`,
|
|
1345
|
-
);
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
const providerName = args.workoutProvider ?? "Soma";
|
|
1349
|
-
const garminWorkout = transformPlannedWorkoutToGarmin(
|
|
1350
|
-
plannedWorkout,
|
|
1351
|
-
providerName,
|
|
1352
|
-
);
|
|
1353
|
-
|
|
1354
|
-
const trainingClient = createTrainingClient(accessToken);
|
|
1355
|
-
|
|
1356
|
-
const { data: created, error: createError } = await sdkCreateWorkoutV2({
|
|
1357
|
-
client: trainingClient,
|
|
1358
|
-
body: garminWorkout,
|
|
1359
|
-
});
|
|
1360
|
-
if (createError || !created) {
|
|
1361
|
-
throw new Error(
|
|
1362
|
-
`Garmin API error creating workout: ${createError ? JSON.stringify(createError) : "No data"}`,
|
|
1363
|
-
);
|
|
1364
|
-
}
|
|
1365
|
-
|
|
1366
|
-
if (!created.workoutId) {
|
|
1367
|
-
throw new Error("Garmin API did not return a workoutId after creation.");
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
let garminScheduleId: number | null = null;
|
|
1371
|
-
|
|
1372
|
-
const plannedDate = plannedWorkout.metadata?.planned_date;
|
|
1373
|
-
if (plannedDate) {
|
|
1374
|
-
const { data: scheduleId, error: scheduleError } = await sdkCreateWorkoutSchedule({
|
|
1375
|
-
client: trainingClient,
|
|
1376
|
-
body: { workoutId: Number(created.workoutId), date: plannedDate },
|
|
1377
|
-
});
|
|
1378
|
-
if (scheduleError) {
|
|
1379
|
-
throw new Error(
|
|
1380
|
-
`Garmin API error creating schedule: ${JSON.stringify(scheduleError)}`,
|
|
1381
|
-
);
|
|
1382
|
-
}
|
|
1383
|
-
garminScheduleId = scheduleId ?? null;
|
|
1384
|
-
}
|
|
1385
|
-
|
|
1386
|
-
// Store the Garmin workout/schedule IDs back on the planned workout
|
|
1387
|
-
// so the host app can match completed activities to planned sessions.
|
|
1388
|
-
const _ingested: Id<"plannedWorkouts"> = await ctx.runMutation(api.public.ingestPlannedWorkout, {
|
|
1389
|
-
...plannedWorkout,
|
|
1390
|
-
_id: undefined,
|
|
1391
|
-
_creationTime: undefined,
|
|
1392
|
-
metadata: {
|
|
1393
|
-
...plannedWorkout.metadata,
|
|
1394
|
-
provider_workout_id: String(created.workoutId),
|
|
1395
|
-
provider_schedule_id:
|
|
1396
|
-
garminScheduleId != null ? String(garminScheduleId) : undefined,
|
|
1397
|
-
},
|
|
1398
|
-
} as never);
|
|
1399
|
-
|
|
1400
|
-
return {
|
|
1401
|
-
garminWorkoutId: created.workoutId,
|
|
1402
|
-
garminScheduleId,
|
|
1403
|
-
};
|
|
1404
|
-
},
|
|
1405
|
-
});
|
|
1406
|
-
|
|
1
|
+
// ─── Garmin Public Actions ───────────────────────────────────────────────────
|
|
2
|
+
// Public actions that handle the full Garmin OAuth 2.0 PKCE + sync lifecycle.
|
|
3
|
+
// The host app calls these through the Soma class, which threads the
|
|
4
|
+
// credentials automatically from env vars or constructor config.
|
|
5
|
+
|
|
6
|
+
import { v } from "convex/values";
|
|
7
|
+
import { action } from "../_generated/server";
|
|
8
|
+
import type { Doc, Id } from "../_generated/dataModel";
|
|
9
|
+
import { generateState } from "../utils.js";
|
|
10
|
+
import {
|
|
11
|
+
generateCodeVerifier,
|
|
12
|
+
generateCodeChallenge,
|
|
13
|
+
buildAuthUrl,
|
|
14
|
+
exchangeCode,
|
|
15
|
+
refreshToken,
|
|
16
|
+
} from "./auth.js";
|
|
17
|
+
import {
|
|
18
|
+
createWellnessClient,
|
|
19
|
+
createTrainingClient,
|
|
20
|
+
} from "./client.js";
|
|
21
|
+
import { buildTimeRangeQuery } from "./utils.js";
|
|
22
|
+
import {
|
|
23
|
+
createWorkoutV2 as sdkCreateWorkoutV2,
|
|
24
|
+
createWorkoutSchedule as sdkCreateWorkoutSchedule,
|
|
25
|
+
updateWorkoutV2 as sdkUpdateWorkoutV2,
|
|
26
|
+
updateWorkoutSchedule as sdkUpdateWorkoutSchedule,
|
|
27
|
+
deleteWorkoutV2 as sdkDeleteWorkoutV2,
|
|
28
|
+
deleteWorkoutSchedule as sdkDeleteWorkoutSchedule,
|
|
29
|
+
} from "./types/trainingApiWorkouts/sdk.gen";
|
|
30
|
+
import {
|
|
31
|
+
userId as sdkUserId,
|
|
32
|
+
dereg as sdkDereg,
|
|
33
|
+
getActivities,
|
|
34
|
+
getDailies,
|
|
35
|
+
getSleeps,
|
|
36
|
+
getBodyComps,
|
|
37
|
+
getMct,
|
|
38
|
+
getBloodPressures,
|
|
39
|
+
getSkinTemp,
|
|
40
|
+
getUserMetrics,
|
|
41
|
+
getHrv,
|
|
42
|
+
getStressDetails,
|
|
43
|
+
getPulseox,
|
|
44
|
+
getRespiration,
|
|
45
|
+
} from "./types/wellnessApi/sdk.gen";
|
|
46
|
+
import { transformActivity } from "./transform/activity.js";
|
|
47
|
+
import { transformDailies } from "./transform/dailies.js";
|
|
48
|
+
import { transformSleeps } from "./transform/sleeps.js";
|
|
49
|
+
import { transformBodyComposition } from "./transform/bodyCompositions.js";
|
|
50
|
+
import { transformMenstrualCycleTracking } from "./transform/menstrualCycleTracking.js";
|
|
51
|
+
import { transformBloodPressure } from "./transform/bloodPressure.js";
|
|
52
|
+
import { transformSkinTemperature } from "./transform/skinTemperature.js";
|
|
53
|
+
import { transformUserMetrics } from "./transform/userMetrics.js";
|
|
54
|
+
import { transformHRVSummary } from "./transform/hrvSummary.js";
|
|
55
|
+
import { transformStress } from "./transform/stress.js";
|
|
56
|
+
import { transformPulseOx } from "./transform/pulseOx.js";
|
|
57
|
+
import { transformRespiration } from "./transform/respiration.js";
|
|
58
|
+
import { transformPlannedWorkoutToGarmin } from "./transform/plannedWorkout.js";
|
|
59
|
+
import { api, internal } from "../_generated/api";
|
|
60
|
+
|
|
61
|
+
// ─── OAuth ──────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export const getGarminAuthUrl = action({
|
|
64
|
+
args: {
|
|
65
|
+
clientId: v.string(),
|
|
66
|
+
redirectUri: v.optional(v.string()),
|
|
67
|
+
userId: v.string(),
|
|
68
|
+
},
|
|
69
|
+
handler: async (ctx, args) => {
|
|
70
|
+
const codeVerifier = generateCodeVerifier();
|
|
71
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
72
|
+
const state = generateState();
|
|
73
|
+
|
|
74
|
+
const authUrl = buildAuthUrl({
|
|
75
|
+
clientId: args.clientId,
|
|
76
|
+
codeChallenge,
|
|
77
|
+
redirectUri: args.redirectUri,
|
|
78
|
+
state,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await ctx.runMutation(internal.garmin.private.storePendingOAuth, {
|
|
82
|
+
provider: "GARMIN",
|
|
83
|
+
state,
|
|
84
|
+
codeVerifier,
|
|
85
|
+
userId: args.userId,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return { authUrl, state, codeVerifier };
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const completeGarminOAuth = action({
|
|
93
|
+
args: {
|
|
94
|
+
code: v.string(),
|
|
95
|
+
state: v.string(),
|
|
96
|
+
clientId: v.string(),
|
|
97
|
+
clientSecret: v.string(),
|
|
98
|
+
redirectUri: v.optional(v.string()),
|
|
99
|
+
},
|
|
100
|
+
handler: async (ctx, args): Promise<{
|
|
101
|
+
connectionId: Id<"connections">;
|
|
102
|
+
userId: string;
|
|
103
|
+
}> => {
|
|
104
|
+
const pending: Doc<"pendingOAuth"> | null = await ctx.runQuery(internal.garmin.private.getPendingOAuth, {
|
|
105
|
+
state: args.state,
|
|
106
|
+
});
|
|
107
|
+
if (!pending) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"No pending Garmin OAuth state found for this state parameter. " +
|
|
110
|
+
"The authorization may have expired or was already used.",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!pending.codeVerifier) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
"No code verifier found for this state parameter. " +
|
|
117
|
+
"The authorization may have expired or was already used.",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const tokenResult = await exchangeCode({
|
|
122
|
+
clientId: args.clientId,
|
|
123
|
+
clientSecret: args.clientSecret,
|
|
124
|
+
code: args.code,
|
|
125
|
+
codeVerifier: pending.codeVerifier,
|
|
126
|
+
redirectUri: args.redirectUri,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const _deleted: null = await ctx.runMutation(internal.garmin.private.deletePendingOAuth, {
|
|
130
|
+
state: args.state,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const connectionId: Id<"connections"> = await ctx.runMutation(api.public.connect, {
|
|
134
|
+
userId: pending.userId,
|
|
135
|
+
provider: "GARMIN",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
|
|
139
|
+
const _stored: null = await ctx.runMutation(internal.garmin.private.storeTokens, {
|
|
140
|
+
connectionId,
|
|
141
|
+
accessToken: tokenResult.access_token,
|
|
142
|
+
refreshToken: tokenResult.refresh_token,
|
|
143
|
+
expiresAt,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Best-effort: resolve Garmin user ID for webhook mapping
|
|
147
|
+
const wellnessClient = createWellnessClient(tokenResult.access_token);
|
|
148
|
+
const { data: userIdData } = await sdkUserId({ client: wellnessClient });
|
|
149
|
+
const garminUserId = userIdData?.userId ?? null;
|
|
150
|
+
if (garminUserId) {
|
|
151
|
+
const _updated: null = await ctx.runMutation(api.public.updateConnection, {
|
|
152
|
+
connectionId,
|
|
153
|
+
providerUserId: garminUserId,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
connectionId,
|
|
159
|
+
userId: pending.userId,
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
export const disconnectGarmin = action({
|
|
165
|
+
args: {
|
|
166
|
+
userId: v.string(),
|
|
167
|
+
},
|
|
168
|
+
handler: async (ctx, args) => {
|
|
169
|
+
const connection: Doc<"connections"> | null = await ctx.runQuery(
|
|
170
|
+
internal.private.getConnectionByProvider,
|
|
171
|
+
{ userId: args.userId, provider: "GARMIN" },
|
|
172
|
+
);
|
|
173
|
+
if (!connection) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`No Garmin connection found for user "${args.userId}".`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const connectionId = connection._id;
|
|
180
|
+
|
|
181
|
+
// Best-effort: deregister user at Garmin
|
|
182
|
+
const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(internal.garmin.private.getTokens, {
|
|
183
|
+
connectionId,
|
|
184
|
+
});
|
|
185
|
+
if (tokenDoc) {
|
|
186
|
+
try {
|
|
187
|
+
const wellnessClient = createWellnessClient(tokenDoc.accessToken);
|
|
188
|
+
await sdkDereg({ client: wellnessClient });
|
|
189
|
+
} catch {
|
|
190
|
+
// Deregistration is best-effort; proceed with local cleanup
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const _deleted: null = await ctx.runMutation(internal.garmin.private.deleteTokens, { connectionId });
|
|
195
|
+
|
|
196
|
+
const _disconnected: null = await ctx.runMutation(api.public.disconnect, {
|
|
197
|
+
userId: args.userId,
|
|
198
|
+
provider: "GARMIN",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return null;
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ─── Pull ───────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
export const pullActivities = action({
|
|
208
|
+
args: {
|
|
209
|
+
userId: v.string(),
|
|
210
|
+
clientId: v.string(),
|
|
211
|
+
clientSecret: v.string(),
|
|
212
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
213
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
214
|
+
},
|
|
215
|
+
handler: async (ctx, args) => {
|
|
216
|
+
|
|
217
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
218
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
219
|
+
{
|
|
220
|
+
userId: args.userId,
|
|
221
|
+
clientId: args.clientId,
|
|
222
|
+
clientSecret: args.clientSecret,
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const timeRangeQuery = buildTimeRangeQuery(args, accessToken);
|
|
227
|
+
|
|
228
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
229
|
+
const synced = { activities: 0 };
|
|
230
|
+
const errors: Array<{ type: "activity"; id: string; error: string }> = [];
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const { data: activities, error } = await getActivities({
|
|
234
|
+
client: wellnessClient,
|
|
235
|
+
query: timeRangeQuery,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (error || !activities) {
|
|
239
|
+
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
for (const activity of activities) {
|
|
243
|
+
try {
|
|
244
|
+
const data = transformActivity(activity);
|
|
245
|
+
await ctx.runMutation(api.public.ingestActivity, {
|
|
246
|
+
connectionId,
|
|
247
|
+
userId: args.userId,
|
|
248
|
+
...data,
|
|
249
|
+
});
|
|
250
|
+
synced.activities++;
|
|
251
|
+
} catch (err) {
|
|
252
|
+
errors.push({
|
|
253
|
+
type: "activity",
|
|
254
|
+
id: activity.summaryId ?? String(activity.activityId),
|
|
255
|
+
error: err instanceof Error ? err.message : String(err),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
errors.push({
|
|
261
|
+
type: "activity",
|
|
262
|
+
id: "fetch",
|
|
263
|
+
error: err instanceof Error ? err.message : String(err),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await ctx.runMutation(api.public.updateConnection, {
|
|
268
|
+
connectionId,
|
|
269
|
+
lastDataUpdate: new Date().toISOString(),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return { synced, errors };
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
export const pullDailies = action({
|
|
277
|
+
args: {
|
|
278
|
+
userId: v.string(),
|
|
279
|
+
clientId: v.string(),
|
|
280
|
+
clientSecret: v.string(),
|
|
281
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
282
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
283
|
+
},
|
|
284
|
+
handler: async (ctx, args) => {
|
|
285
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
286
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
287
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
291
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
292
|
+
const synced = { dailies: 0 };
|
|
293
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
const { data: dailies, error } = await getDailies({ client: wellnessClient, query });
|
|
297
|
+
if (error || !dailies) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
298
|
+
for (const daily of dailies) {
|
|
299
|
+
try {
|
|
300
|
+
const data = transformDailies(daily);
|
|
301
|
+
if (!data) continue;
|
|
302
|
+
await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
|
|
303
|
+
synced.dailies++;
|
|
304
|
+
} catch (err) {
|
|
305
|
+
errors.push({ type: "daily", id: daily.summaryId ?? daily.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
errors.push({ type: "daily", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
313
|
+
return { synced, errors };
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
export const pullSleep = action({
|
|
318
|
+
args: {
|
|
319
|
+
userId: v.string(),
|
|
320
|
+
clientId: v.string(),
|
|
321
|
+
clientSecret: v.string(),
|
|
322
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
323
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
324
|
+
},
|
|
325
|
+
handler: async (ctx, args) => {
|
|
326
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
327
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
328
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
332
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
333
|
+
const synced = { sleep: 0 };
|
|
334
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const { data: sleeps, error } = await getSleeps({ client: wellnessClient, query });
|
|
338
|
+
if (error || !sleeps) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
339
|
+
for (const sleep of sleeps) {
|
|
340
|
+
try {
|
|
341
|
+
const data = transformSleeps(sleep);
|
|
342
|
+
await ctx.runMutation(api.public.ingestSleep, { connectionId, userId: args.userId, ...data });
|
|
343
|
+
synced.sleep++;
|
|
344
|
+
} catch (err) {
|
|
345
|
+
errors.push({ type: "sleep", id: sleep.summaryId ?? sleep.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} catch (err) {
|
|
349
|
+
errors.push({ type: "sleep", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
353
|
+
return { synced, errors };
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
export const pullBody = action({
|
|
358
|
+
args: {
|
|
359
|
+
userId: v.string(),
|
|
360
|
+
clientId: v.string(),
|
|
361
|
+
clientSecret: v.string(),
|
|
362
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
363
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
364
|
+
},
|
|
365
|
+
handler: async (ctx, args) => {
|
|
366
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
367
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
368
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
372
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
373
|
+
const synced = { body: 0 };
|
|
374
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
const { data: bodyComps, error } = await getBodyComps({ client: wellnessClient, query });
|
|
378
|
+
if (error || !bodyComps) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
379
|
+
for (const body of bodyComps) {
|
|
380
|
+
try {
|
|
381
|
+
const data = transformBodyComposition(body);
|
|
382
|
+
if (!data) continue;
|
|
383
|
+
await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
|
|
384
|
+
synced.body++;
|
|
385
|
+
} catch (err) {
|
|
386
|
+
errors.push({ type: "body", id: body.summaryId ?? String(body.measurementTimeInSeconds), error: err instanceof Error ? err.message : String(err) });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
} catch (err) {
|
|
390
|
+
errors.push({ type: "body", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
394
|
+
return { synced, errors };
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
export const pullMenstruation = action({
|
|
399
|
+
args: {
|
|
400
|
+
userId: v.string(),
|
|
401
|
+
clientId: v.string(),
|
|
402
|
+
clientSecret: v.string(),
|
|
403
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
404
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
405
|
+
},
|
|
406
|
+
handler: async (ctx, args) => {
|
|
407
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
408
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
409
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
413
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
414
|
+
const synced = { menstruation: 0 };
|
|
415
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
416
|
+
|
|
417
|
+
try {
|
|
418
|
+
const { data: records, error } = await getMct({ client: wellnessClient, query });
|
|
419
|
+
if (error || !records) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
420
|
+
for (const record of records) {
|
|
421
|
+
try {
|
|
422
|
+
const data = transformMenstrualCycleTracking(record);
|
|
423
|
+
await ctx.runMutation(api.public.ingestMenstruation, { connectionId, userId: args.userId, ...data });
|
|
424
|
+
synced.menstruation++;
|
|
425
|
+
} catch (err) {
|
|
426
|
+
errors.push({ type: "menstruation", id: record.summaryId ?? record.periodStartDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
errors.push({ type: "menstruation", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
434
|
+
return { synced, errors };
|
|
435
|
+
},
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
export const pullBloodPressures = action({
|
|
439
|
+
args: {
|
|
440
|
+
userId: v.string(),
|
|
441
|
+
clientId: v.string(),
|
|
442
|
+
clientSecret: v.string(),
|
|
443
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
444
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
445
|
+
},
|
|
446
|
+
handler: async (ctx, args) => {
|
|
447
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
448
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
449
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
453
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
454
|
+
const synced = { bloodPressures: 0 };
|
|
455
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const { data: bpRecords, error } = await getBloodPressures({ client: wellnessClient, query });
|
|
459
|
+
if (error || !bpRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
460
|
+
for (const bp of bpRecords) {
|
|
461
|
+
try {
|
|
462
|
+
const data = transformBloodPressure(bp);
|
|
463
|
+
if (!data) continue;
|
|
464
|
+
await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
|
|
465
|
+
synced.bloodPressures++;
|
|
466
|
+
} catch (err) {
|
|
467
|
+
errors.push({ type: "bloodPressure", id: bp.summaryId ?? String(bp.measurementTimeInSeconds), error: err instanceof Error ? err.message : String(err) });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} catch (err) {
|
|
471
|
+
errors.push({ type: "bloodPressure", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
475
|
+
return { synced, errors };
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
export const pullSkinTemperature = action({
|
|
480
|
+
args: {
|
|
481
|
+
userId: v.string(),
|
|
482
|
+
clientId: v.string(),
|
|
483
|
+
clientSecret: v.string(),
|
|
484
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
485
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
486
|
+
},
|
|
487
|
+
handler: async (ctx, args) => {
|
|
488
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
489
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
490
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
494
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
495
|
+
const synced = { skinTemp: 0 };
|
|
496
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const { data: skinRecords, error } = await getSkinTemp({ client: wellnessClient, query });
|
|
500
|
+
if (error || !skinRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
501
|
+
for (const skin of skinRecords) {
|
|
502
|
+
try {
|
|
503
|
+
const data = transformSkinTemperature(skin);
|
|
504
|
+
if (!data) continue;
|
|
505
|
+
await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
|
|
506
|
+
synced.skinTemp++;
|
|
507
|
+
} catch (err) {
|
|
508
|
+
errors.push({ type: "skinTemp", id: skin.summaryId ?? skin.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
} catch (err) {
|
|
512
|
+
errors.push({ type: "skinTemp", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
516
|
+
return { synced, errors };
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
export const pullUserMetrics = action({
|
|
521
|
+
args: {
|
|
522
|
+
userId: v.string(),
|
|
523
|
+
clientId: v.string(),
|
|
524
|
+
clientSecret: v.string(),
|
|
525
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
526
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
527
|
+
},
|
|
528
|
+
handler: async (ctx, args) => {
|
|
529
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
530
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
531
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
535
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
536
|
+
const synced = { userMetrics: 0 };
|
|
537
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
const { data: metricsRecords, error } = await getUserMetrics({ client: wellnessClient, query });
|
|
541
|
+
if (error || !metricsRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
542
|
+
for (const metrics of metricsRecords) {
|
|
543
|
+
try {
|
|
544
|
+
const data = transformUserMetrics(metrics);
|
|
545
|
+
if (!data) continue;
|
|
546
|
+
await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
|
|
547
|
+
synced.userMetrics++;
|
|
548
|
+
} catch (err) {
|
|
549
|
+
errors.push({ type: "userMetrics", id: metrics.summaryId ?? metrics.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
} catch (err) {
|
|
553
|
+
errors.push({ type: "userMetrics", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
557
|
+
return { synced, errors };
|
|
558
|
+
},
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
export const pullHRV = action({
|
|
562
|
+
args: {
|
|
563
|
+
userId: v.string(),
|
|
564
|
+
clientId: v.string(),
|
|
565
|
+
clientSecret: v.string(),
|
|
566
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
567
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
568
|
+
},
|
|
569
|
+
handler: async (ctx, args) => {
|
|
570
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
571
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
572
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
576
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
577
|
+
const synced = { hrv: 0 };
|
|
578
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
const { data: hrvRecords, error } = await getHrv({ client: wellnessClient, query });
|
|
582
|
+
if (error || !hrvRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
583
|
+
for (const hrv of hrvRecords) {
|
|
584
|
+
try {
|
|
585
|
+
const data = transformHRVSummary(hrv);
|
|
586
|
+
if (!data) continue;
|
|
587
|
+
await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
|
|
588
|
+
synced.hrv++;
|
|
589
|
+
} catch (err) {
|
|
590
|
+
errors.push({ type: "hrv", id: hrv.summaryId ?? hrv.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} catch (err) {
|
|
594
|
+
errors.push({ type: "hrv", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
598
|
+
return { synced, errors };
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
export const pullStressDetails = action({
|
|
603
|
+
args: {
|
|
604
|
+
userId: v.string(),
|
|
605
|
+
clientId: v.string(),
|
|
606
|
+
clientSecret: v.string(),
|
|
607
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
608
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
609
|
+
},
|
|
610
|
+
handler: async (ctx, args) => {
|
|
611
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
612
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
613
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
617
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
618
|
+
const synced = { stressDetails: 0 };
|
|
619
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
const { data: stressRecords, error } = await getStressDetails({ client: wellnessClient, query });
|
|
623
|
+
if (error || !stressRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
624
|
+
for (const stress of stressRecords) {
|
|
625
|
+
try {
|
|
626
|
+
const data = transformStress(stress);
|
|
627
|
+
if (!data) continue;
|
|
628
|
+
await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
|
|
629
|
+
synced.stressDetails++;
|
|
630
|
+
} catch (err) {
|
|
631
|
+
errors.push({ type: "stressDetails", id: stress.summaryId ?? stress.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} catch (err) {
|
|
635
|
+
errors.push({ type: "stressDetails", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
639
|
+
return { synced, errors };
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
export const pullPulseOx = action({
|
|
644
|
+
args: {
|
|
645
|
+
userId: v.string(),
|
|
646
|
+
clientId: v.string(),
|
|
647
|
+
clientSecret: v.string(),
|
|
648
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
649
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
650
|
+
},
|
|
651
|
+
handler: async (ctx, args) => {
|
|
652
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
653
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
654
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
658
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
659
|
+
const synced = { pulseOx: 0 };
|
|
660
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
const { data: pulseOxRecords, error } = await getPulseox({ client: wellnessClient, query });
|
|
664
|
+
if (error || !pulseOxRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
665
|
+
for (const po of pulseOxRecords) {
|
|
666
|
+
try {
|
|
667
|
+
const data = transformPulseOx(po);
|
|
668
|
+
if (!data) continue;
|
|
669
|
+
await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
|
|
670
|
+
synced.pulseOx++;
|
|
671
|
+
} catch (err) {
|
|
672
|
+
errors.push({ type: "pulseOx", id: po.summaryId ?? po.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
} catch (err) {
|
|
676
|
+
errors.push({ type: "pulseOx", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
680
|
+
return { synced, errors };
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
export const pullRespiration = action({
|
|
685
|
+
args: {
|
|
686
|
+
userId: v.string(),
|
|
687
|
+
clientId: v.string(),
|
|
688
|
+
clientSecret: v.string(),
|
|
689
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
690
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
691
|
+
},
|
|
692
|
+
handler: async (ctx, args) => {
|
|
693
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
694
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
695
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
696
|
+
);
|
|
697
|
+
|
|
698
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
699
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
700
|
+
const synced = { respiration: 0 };
|
|
701
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
702
|
+
|
|
703
|
+
try {
|
|
704
|
+
const { data: respRecords, error } = await getRespiration({ client: wellnessClient, query });
|
|
705
|
+
if (error || !respRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
706
|
+
for (const resp of respRecords) {
|
|
707
|
+
try {
|
|
708
|
+
const data = transformRespiration(resp);
|
|
709
|
+
if (!data) continue;
|
|
710
|
+
await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
|
|
711
|
+
synced.respiration++;
|
|
712
|
+
} catch (err) {
|
|
713
|
+
errors.push({ type: "respiration", id: resp.summaryId ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
} catch (err) {
|
|
717
|
+
errors.push({ type: "respiration", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
721
|
+
return { synced, errors };
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
export const pullAll = action({
|
|
726
|
+
args: {
|
|
727
|
+
userId: v.string(),
|
|
728
|
+
clientId: v.string(),
|
|
729
|
+
clientSecret: v.string(),
|
|
730
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
731
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
732
|
+
},
|
|
733
|
+
handler: async (ctx, args) => {
|
|
734
|
+
const sharedArgs = {
|
|
735
|
+
userId: args.userId,
|
|
736
|
+
clientId: args.clientId,
|
|
737
|
+
clientSecret: args.clientSecret,
|
|
738
|
+
startTimeInSeconds: args.startTimeInSeconds,
|
|
739
|
+
endTimeInSeconds: args.endTimeInSeconds,
|
|
740
|
+
};
|
|
741
|
+
const pullFns = [
|
|
742
|
+
{ ref: api.garmin.public.pullActivities, name: "activities" },
|
|
743
|
+
{ ref: api.garmin.public.pullDailies, name: "dailies" },
|
|
744
|
+
{ ref: api.garmin.public.pullSleep, name: "sleep" },
|
|
745
|
+
{ ref: api.garmin.public.pullBody, name: "body" },
|
|
746
|
+
{ ref: api.garmin.public.pullMenstruation, name: "menstruation" },
|
|
747
|
+
{ ref: api.garmin.public.pullBloodPressures, name: "bloodPressures" },
|
|
748
|
+
{ ref: api.garmin.public.pullSkinTemperature, name: "skinTemp" },
|
|
749
|
+
{ ref: api.garmin.public.pullUserMetrics, name: "userMetrics" },
|
|
750
|
+
{ ref: api.garmin.public.pullHRV, name: "hrv" },
|
|
751
|
+
{ ref: api.garmin.public.pullStressDetails, name: "stressDetails" },
|
|
752
|
+
{ ref: api.garmin.public.pullPulseOx, name: "pulseOx" },
|
|
753
|
+
{ ref: api.garmin.public.pullRespiration, name: "respiration" },
|
|
754
|
+
];
|
|
755
|
+
const synced: Record<string, number> = {};
|
|
756
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
757
|
+
for (const { ref, name } of pullFns) {
|
|
758
|
+
try {
|
|
759
|
+
const result = await ctx.runAction(ref, sharedArgs);
|
|
760
|
+
Object.assign(synced, result.synced);
|
|
761
|
+
errors.push(...result.errors);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
errors.push({
|
|
764
|
+
type: name,
|
|
765
|
+
id: "pull",
|
|
766
|
+
error: err instanceof Error ? err.message : String(err),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return { synced, errors };
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
// ─── Push ───────────────────────────────────────────────────────────────────
|
|
776
|
+
|
|
777
|
+
export const pushWorkout = action({
|
|
778
|
+
args: {
|
|
779
|
+
userId: v.string(),
|
|
780
|
+
clientId: v.string(),
|
|
781
|
+
clientSecret: v.string(),
|
|
782
|
+
plannedWorkoutId: v.string(),
|
|
783
|
+
workoutProvider: v.optional(v.string()),
|
|
784
|
+
},
|
|
785
|
+
handler: async (ctx, args): Promise<{ garminWorkoutId: number }> => {
|
|
786
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
787
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
788
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
const plannedWorkout: Doc<"plannedWorkouts"> | null = await ctx.runQuery(
|
|
792
|
+
api.public.getPlannedWorkout,
|
|
793
|
+
{ plannedWorkoutId: args.plannedWorkoutId as never },
|
|
794
|
+
);
|
|
795
|
+
if (!plannedWorkout) {
|
|
796
|
+
throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
const providerName = args.workoutProvider ?? "Soma";
|
|
800
|
+
const garminWorkout = transformPlannedWorkoutToGarmin(plannedWorkout, providerName);
|
|
801
|
+
|
|
802
|
+
const trainingClient = createTrainingClient(accessToken);
|
|
803
|
+
|
|
804
|
+
const existingWorkoutId = plannedWorkout.metadata?.provider_workout_id;
|
|
805
|
+
let workoutId: number;
|
|
806
|
+
|
|
807
|
+
if (existingWorkoutId) {
|
|
808
|
+
// Update existing workout on Garmin
|
|
809
|
+
const numericId = Number(existingWorkoutId);
|
|
810
|
+
garminWorkout.workoutId = numericId;
|
|
811
|
+
const { error: updateError } = await sdkUpdateWorkoutV2({
|
|
812
|
+
client: trainingClient,
|
|
813
|
+
body: garminWorkout,
|
|
814
|
+
path: { workoutId: numericId },
|
|
815
|
+
});
|
|
816
|
+
if (updateError) {
|
|
817
|
+
throw new Error(
|
|
818
|
+
`Garmin API error updating workout: ${JSON.stringify(updateError)}`,
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
workoutId = numericId;
|
|
822
|
+
} else {
|
|
823
|
+
// Create new workout on Garmin
|
|
824
|
+
const { data: created, error: createError } = await sdkCreateWorkoutV2({
|
|
825
|
+
client: trainingClient,
|
|
826
|
+
body: garminWorkout,
|
|
827
|
+
});
|
|
828
|
+
if (createError || !created) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`Garmin API error creating workout: ${createError ? JSON.stringify(createError) : "No data"}`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
if (!created.workoutId) {
|
|
834
|
+
throw new Error("Garmin API did not return a workoutId after creation.");
|
|
835
|
+
}
|
|
836
|
+
workoutId = created.workoutId;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Persist the Garmin workout ID back on the planned workout
|
|
840
|
+
const _ingested: Id<"plannedWorkouts"> = await ctx.runMutation(api.public.ingestPlannedWorkout, {
|
|
841
|
+
...plannedWorkout,
|
|
842
|
+
_id: undefined,
|
|
843
|
+
_creationTime: undefined,
|
|
844
|
+
metadata: {
|
|
845
|
+
...plannedWorkout.metadata,
|
|
846
|
+
provider_workout_id: String(workoutId),
|
|
847
|
+
},
|
|
848
|
+
} as never);
|
|
849
|
+
|
|
850
|
+
return { garminWorkoutId: workoutId };
|
|
851
|
+
},
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
export const pushSchedule = action({
|
|
855
|
+
args: {
|
|
856
|
+
userId: v.string(),
|
|
857
|
+
clientId: v.string(),
|
|
858
|
+
clientSecret: v.string(),
|
|
859
|
+
plannedWorkoutId: v.string(),
|
|
860
|
+
date: v.optional(v.string()),
|
|
861
|
+
},
|
|
862
|
+
handler: async (ctx, args): Promise<{ garminScheduleId: number }> => {
|
|
863
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
864
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
865
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
866
|
+
);
|
|
867
|
+
|
|
868
|
+
const plannedWorkout: Doc<"plannedWorkouts"> | null = await ctx.runQuery(
|
|
869
|
+
api.public.getPlannedWorkout,
|
|
870
|
+
{ plannedWorkoutId: args.plannedWorkoutId as never },
|
|
871
|
+
);
|
|
872
|
+
if (!plannedWorkout) {
|
|
873
|
+
throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const providerWorkoutId = plannedWorkout.metadata?.provider_workout_id;
|
|
877
|
+
if (!providerWorkoutId) {
|
|
878
|
+
throw new Error(
|
|
879
|
+
"No Garmin workout ID found on this planned workout. Push the workout first via pushWorkout.",
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const scheduleDate = args.date ?? plannedWorkout.metadata?.planned_date;
|
|
884
|
+
if (!scheduleDate) {
|
|
885
|
+
throw new Error(
|
|
886
|
+
"No date provided and no planned_date on the workout. Provide a date argument.",
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const trainingClient = createTrainingClient(accessToken);
|
|
891
|
+
|
|
892
|
+
const existingScheduleId = plannedWorkout.metadata?.provider_schedule_id;
|
|
893
|
+
let scheduleId: number;
|
|
894
|
+
|
|
895
|
+
if (existingScheduleId) {
|
|
896
|
+
// Update existing schedule on Garmin
|
|
897
|
+
const numericScheduleId = Number(existingScheduleId);
|
|
898
|
+
const { error: updateError } = await sdkUpdateWorkoutSchedule({
|
|
899
|
+
client: trainingClient,
|
|
900
|
+
body: { workoutId: Number(providerWorkoutId), date: scheduleDate },
|
|
901
|
+
path: { workoutScheduleId: numericScheduleId },
|
|
902
|
+
});
|
|
903
|
+
if (updateError) {
|
|
904
|
+
throw new Error(
|
|
905
|
+
`Garmin API error updating schedule: ${JSON.stringify(updateError)}`,
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
scheduleId = numericScheduleId;
|
|
909
|
+
} else {
|
|
910
|
+
// Create new schedule on Garmin
|
|
911
|
+
const { data: createdScheduleId, error: scheduleError } = await sdkCreateWorkoutSchedule({
|
|
912
|
+
client: trainingClient,
|
|
913
|
+
body: { workoutId: Number(providerWorkoutId), date: scheduleDate },
|
|
914
|
+
});
|
|
915
|
+
if (scheduleError) {
|
|
916
|
+
throw new Error(
|
|
917
|
+
`Garmin API error creating schedule: ${JSON.stringify(scheduleError)}`,
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
if (createdScheduleId == null) {
|
|
921
|
+
throw new Error("Garmin API did not return a scheduleId after creation.");
|
|
922
|
+
}
|
|
923
|
+
scheduleId = createdScheduleId;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Persist the Garmin schedule ID back on the planned workout
|
|
927
|
+
const _ingested: Id<"plannedWorkouts"> = await ctx.runMutation(api.public.ingestPlannedWorkout, {
|
|
928
|
+
...plannedWorkout,
|
|
929
|
+
_id: undefined,
|
|
930
|
+
_creationTime: undefined,
|
|
931
|
+
metadata: {
|
|
932
|
+
...plannedWorkout.metadata,
|
|
933
|
+
provider_schedule_id: String(scheduleId),
|
|
934
|
+
},
|
|
935
|
+
} as never);
|
|
936
|
+
|
|
937
|
+
return { garminScheduleId: scheduleId };
|
|
938
|
+
},
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
// ─── Delete ───────────────────────────────────────────────────────────────────
|
|
942
|
+
|
|
943
|
+
export const deleteWorkout = action({
|
|
944
|
+
args: {
|
|
945
|
+
userId: v.string(),
|
|
946
|
+
clientId: v.string(),
|
|
947
|
+
clientSecret: v.string(),
|
|
948
|
+
plannedWorkoutId: v.string(),
|
|
949
|
+
},
|
|
950
|
+
handler: async (ctx, args): Promise<null> => {
|
|
951
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
952
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
953
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
const plannedWorkout: Doc<"plannedWorkouts"> | null = await ctx.runQuery(
|
|
957
|
+
api.public.getPlannedWorkout,
|
|
958
|
+
{ plannedWorkoutId: args.plannedWorkoutId as never },
|
|
959
|
+
);
|
|
960
|
+
if (!plannedWorkout) {
|
|
961
|
+
throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const providerWorkoutId = plannedWorkout.metadata?.provider_workout_id;
|
|
965
|
+
if (!providerWorkoutId) {
|
|
966
|
+
throw new Error("No Garmin workout ID found on this planned workout. Nothing to delete.");
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const trainingClient = createTrainingClient(accessToken);
|
|
970
|
+
|
|
971
|
+
const { error: deleteError } = await sdkDeleteWorkoutV2({
|
|
972
|
+
client: trainingClient,
|
|
973
|
+
path: { workoutId: Number(providerWorkoutId) },
|
|
974
|
+
});
|
|
975
|
+
if (deleteError) {
|
|
976
|
+
throw new Error(
|
|
977
|
+
`Garmin API error deleting workout: ${JSON.stringify(deleteError)}`,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Clear both provider IDs — deleting a workout on Garmin cascades to its schedules
|
|
982
|
+
const _ingested: Id<"plannedWorkouts"> = await ctx.runMutation(api.public.ingestPlannedWorkout, {
|
|
983
|
+
...plannedWorkout,
|
|
984
|
+
_id: undefined,
|
|
985
|
+
_creationTime: undefined,
|
|
986
|
+
metadata: {
|
|
987
|
+
...plannedWorkout.metadata,
|
|
988
|
+
provider_workout_id: undefined,
|
|
989
|
+
provider_schedule_id: undefined,
|
|
990
|
+
},
|
|
991
|
+
} as never);
|
|
992
|
+
|
|
993
|
+
return null;
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
export const deleteSchedule = action({
|
|
998
|
+
args: {
|
|
999
|
+
userId: v.string(),
|
|
1000
|
+
clientId: v.string(),
|
|
1001
|
+
clientSecret: v.string(),
|
|
1002
|
+
plannedWorkoutId: v.string(),
|
|
1003
|
+
},
|
|
1004
|
+
handler: async (ctx, args): Promise<null> => {
|
|
1005
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
1006
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
1007
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
1008
|
+
);
|
|
1009
|
+
|
|
1010
|
+
const plannedWorkout: Doc<"plannedWorkouts"> | null = await ctx.runQuery(
|
|
1011
|
+
api.public.getPlannedWorkout,
|
|
1012
|
+
{ plannedWorkoutId: args.plannedWorkoutId as never },
|
|
1013
|
+
);
|
|
1014
|
+
if (!plannedWorkout) {
|
|
1015
|
+
throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const providerScheduleId = plannedWorkout.metadata?.provider_schedule_id;
|
|
1019
|
+
if (!providerScheduleId) {
|
|
1020
|
+
throw new Error("No Garmin schedule ID found on this planned workout. Nothing to delete.");
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
const trainingClient = createTrainingClient(accessToken);
|
|
1024
|
+
|
|
1025
|
+
const { error: deleteError } = await sdkDeleteWorkoutSchedule({
|
|
1026
|
+
client: trainingClient,
|
|
1027
|
+
path: { workoutScheduleId: Number(providerScheduleId) },
|
|
1028
|
+
});
|
|
1029
|
+
if (deleteError) {
|
|
1030
|
+
throw new Error(
|
|
1031
|
+
`Garmin API error deleting schedule: ${JSON.stringify(deleteError)}`,
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Clear only the schedule ID — the workout still exists on Garmin
|
|
1036
|
+
const _ingested: Id<"plannedWorkouts"> = await ctx.runMutation(api.public.ingestPlannedWorkout, {
|
|
1037
|
+
...plannedWorkout,
|
|
1038
|
+
_id: undefined,
|
|
1039
|
+
_creationTime: undefined,
|
|
1040
|
+
metadata: {
|
|
1041
|
+
...plannedWorkout.metadata,
|
|
1042
|
+
provider_schedule_id: undefined,
|
|
1043
|
+
},
|
|
1044
|
+
} as never);
|
|
1045
|
+
|
|
1046
|
+
return null;
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
|