@nativesquare/soma 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dist/client/index.d.ts +83 -0
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +131 -0
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +159 -0
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/garmin.d.ts +190 -6
  8. package/dist/component/garmin.d.ts.map +1 -1
  9. package/dist/component/garmin.js +805 -25
  10. package/dist/component/garmin.js.map +1 -1
  11. package/dist/component/private.d.ts +18 -0
  12. package/dist/component/private.d.ts.map +1 -1
  13. package/dist/component/private.js +18 -0
  14. package/dist/component/private.js.map +1 -1
  15. package/dist/component/public.d.ts +88 -42
  16. package/dist/component/public.d.ts.map +1 -1
  17. package/dist/component/public.js +12 -2
  18. package/dist/component/public.js.map +1 -1
  19. package/dist/component/schema.d.ts +87 -32
  20. package/dist/component/schema.d.ts.map +1 -1
  21. package/dist/component/schema.js +2 -1
  22. package/dist/component/schema.js.map +1 -1
  23. package/dist/component/validators/connection.d.ts +1 -0
  24. package/dist/component/validators/connection.d.ts.map +1 -1
  25. package/dist/component/validators/connection.js +2 -0
  26. package/dist/component/validators/connection.js.map +1 -1
  27. package/dist/component/validators/daily.d.ts +40 -5
  28. package/dist/component/validators/daily.d.ts.map +1 -1
  29. package/dist/component/validators/daily.js +10 -1
  30. package/dist/component/validators/daily.js.map +1 -1
  31. package/dist/component/validators/enums.d.ts +1 -1
  32. package/dist/component/validators/plannedWorkout.d.ts +5 -1
  33. package/dist/component/validators/plannedWorkout.d.ts.map +1 -1
  34. package/dist/component/validators/plannedWorkout.js +4 -0
  35. package/dist/component/validators/plannedWorkout.js.map +1 -1
  36. package/dist/component/validators/sleep.d.ts +8 -8
  37. package/dist/garmin/activity.d.ts +7 -16
  38. package/dist/garmin/activity.d.ts.map +1 -1
  39. package/dist/garmin/activity.js +17 -23
  40. package/dist/garmin/activity.js.map +1 -1
  41. package/dist/garmin/bloodPressure.d.ts +28 -0
  42. package/dist/garmin/bloodPressure.d.ts.map +1 -0
  43. package/dist/garmin/bloodPressure.js +34 -0
  44. package/dist/garmin/bloodPressure.js.map +1 -0
  45. package/dist/garmin/body.js +1 -1
  46. package/dist/garmin/body.js.map +1 -1
  47. package/dist/garmin/client.d.ts +117 -17
  48. package/dist/garmin/client.d.ts.map +1 -1
  49. package/dist/garmin/client.js +337 -43
  50. package/dist/garmin/client.js.map +1 -1
  51. package/dist/garmin/daily.d.ts.map +1 -1
  52. package/dist/garmin/daily.js +3 -3
  53. package/dist/garmin/daily.js.map +1 -1
  54. package/dist/garmin/hrv.d.ts +30 -0
  55. package/dist/garmin/hrv.d.ts.map +1 -0
  56. package/dist/garmin/hrv.js +45 -0
  57. package/dist/garmin/hrv.js.map +1 -0
  58. package/dist/garmin/index.d.ts +16 -2
  59. package/dist/garmin/index.d.ts.map +1 -1
  60. package/dist/garmin/index.js +8 -1
  61. package/dist/garmin/index.js.map +1 -1
  62. package/dist/garmin/maps/activity-type.d.ts +1 -2
  63. package/dist/garmin/maps/activity-type.d.ts.map +1 -1
  64. package/dist/garmin/maps/activity-type.js +1 -0
  65. package/dist/garmin/maps/activity-type.js.map +1 -1
  66. package/dist/garmin/menstruation.d.ts +6 -4
  67. package/dist/garmin/menstruation.d.ts.map +1 -1
  68. package/dist/garmin/menstruation.js +12 -8
  69. package/dist/garmin/menstruation.js.map +1 -1
  70. package/dist/garmin/pulseOx.d.ts +24 -0
  71. package/dist/garmin/pulseOx.d.ts.map +1 -0
  72. package/dist/garmin/pulseOx.js +33 -0
  73. package/dist/garmin/pulseOx.js.map +1 -0
  74. package/dist/garmin/respiration.d.ts +29 -0
  75. package/dist/garmin/respiration.d.ts.map +1 -0
  76. package/dist/garmin/respiration.js +42 -0
  77. package/dist/garmin/respiration.js.map +1 -0
  78. package/dist/garmin/skinTemp.d.ts +27 -0
  79. package/dist/garmin/skinTemp.d.ts.map +1 -0
  80. package/dist/garmin/skinTemp.js +35 -0
  81. package/dist/garmin/skinTemp.js.map +1 -0
  82. package/dist/garmin/sleep.d.ts +4 -4
  83. package/dist/garmin/sleep.d.ts.map +1 -1
  84. package/dist/garmin/sleep.js +15 -9
  85. package/dist/garmin/sleep.js.map +1 -1
  86. package/dist/garmin/stressDetails.d.ts +30 -0
  87. package/dist/garmin/stressDetails.d.ts.map +1 -0
  88. package/dist/garmin/stressDetails.js +49 -0
  89. package/dist/garmin/stressDetails.js.map +1 -0
  90. package/dist/garmin/sync.d.ts +14 -0
  91. package/dist/garmin/sync.d.ts.map +1 -1
  92. package/dist/garmin/sync.js +287 -5
  93. package/dist/garmin/sync.js.map +1 -1
  94. package/dist/garmin/types.d.ts +77 -186
  95. package/dist/garmin/types.d.ts.map +1 -1
  96. package/dist/garmin/types.js +4 -2
  97. package/dist/garmin/types.js.map +1 -1
  98. package/dist/garmin/userMetrics.d.ts +23 -0
  99. package/dist/garmin/userMetrics.d.ts.map +1 -0
  100. package/dist/garmin/userMetrics.js +41 -0
  101. package/dist/garmin/userMetrics.js.map +1 -0
  102. package/dist/validators.d.ts +107 -28
  103. package/dist/validators.d.ts.map +1 -1
  104. package/package.json +133 -124
  105. package/src/client/index.ts +199 -0
  106. package/src/component/_generated/component.ts +161 -2
  107. package/src/component/garmin.ts +898 -26
  108. package/src/component/private.ts +21 -0
  109. package/src/component/public.ts +11 -2
  110. package/src/component/schema.ts +2 -1
  111. package/src/component/validators/connection.ts +2 -0
  112. package/src/component/validators/daily.ts +15 -0
  113. package/src/component/validators/plannedWorkout.ts +4 -0
  114. package/src/garmin/activity.test.ts +13 -21
  115. package/src/garmin/activity.ts +38 -45
  116. package/src/garmin/bloodPressure.ts +41 -0
  117. package/src/garmin/body.ts +1 -1
  118. package/src/garmin/client.ts +550 -71
  119. package/src/garmin/daily.ts +8 -4
  120. package/src/garmin/hrv.ts +57 -0
  121. package/src/garmin/index.ts +77 -7
  122. package/src/garmin/maps/activity-type.ts +2 -2
  123. package/src/garmin/menstruation.ts +14 -12
  124. package/src/garmin/pulseOx.ts +45 -0
  125. package/src/garmin/respiration.ts +55 -0
  126. package/src/garmin/skinTemp.ts +42 -0
  127. package/src/garmin/sleep.test.ts +5 -6
  128. package/src/garmin/sleep.ts +22 -16
  129. package/src/garmin/spec/wellness-api.json +1 -0
  130. package/src/garmin/stressDetails.ts +71 -0
  131. package/src/garmin/sync.ts +348 -5
  132. package/src/garmin/types.ts +88 -300
  133. package/src/garmin/userMetrics.ts +50 -0
  134. package/src/garmin/wellness-api.d.ts +5637 -0
@@ -14,6 +14,13 @@ import { transformDaily } from "../garmin/daily.js";
14
14
  import { transformSleep } from "../garmin/sleep.js";
15
15
  import { transformBody } from "../garmin/body.js";
16
16
  import { transformMenstruation } from "../garmin/menstruation.js";
17
+ import { transformBloodPressure } from "../garmin/bloodPressure.js";
18
+ import { transformSkinTemp } from "../garmin/skinTemp.js";
19
+ import { transformUserMetrics } from "../garmin/userMetrics.js";
20
+ import { transformHRV } from "../garmin/hrv.js";
21
+ import { transformStressDetails } from "../garmin/stressDetails.js";
22
+ import { transformPulseOx } from "../garmin/pulseOx.js";
23
+ import { transformRespiration } from "../garmin/respiration.js";
17
24
  import { transformPlannedWorkoutToGarmin } from "../garmin/plannedWorkout.js";
18
25
  // Use anyApi to avoid circular type references between this file and _generated/api.ts.
19
26
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -22,6 +29,21 @@ const publicApi = anyApi;
22
29
  const internalApi = anyApi;
23
30
  // Default sync window: last 30 days
24
31
  const DEFAULT_SYNC_DAYS = 30;
32
+ // Shared synced counter validator for all sync actions
33
+ const syncedValidator = v.object({
34
+ activities: v.number(),
35
+ dailies: v.number(),
36
+ sleep: v.number(),
37
+ body: v.number(),
38
+ menstruation: v.number(),
39
+ bloodPressures: v.number(),
40
+ skinTemp: v.number(),
41
+ userMetrics: v.number(),
42
+ hrv: v.number(),
43
+ stressDetails: v.number(),
44
+ pulseOx: v.number(),
45
+ respiration: v.number(),
46
+ });
25
47
  // Refresh buffer: refresh tokens 10 minutes before expiry
26
48
  const REFRESH_BUFFER_SECONDS = 600;
27
49
  // ─── Internal Pending OAuth CRUD ─────────────────────────────────────────────
@@ -208,13 +230,7 @@ export const connectGarmin = action({
208
230
  },
209
231
  returns: v.object({
210
232
  connectionId: v.string(),
211
- synced: v.object({
212
- activities: v.number(),
213
- dailies: v.number(),
214
- sleep: v.number(),
215
- body: v.number(),
216
- menstruation: v.number(),
217
- }),
233
+ synced: syncedValidator,
218
234
  errors: v.array(v.object({ type: v.string(), id: v.string(), error: v.string() })),
219
235
  }),
220
236
  handler: async (ctx, args) => {
@@ -239,6 +255,14 @@ export const connectGarmin = action({
239
255
  const client = new GarminClient({
240
256
  accessToken: tokenResult.access_token,
241
257
  });
258
+ // Best-effort: resolve Garmin user ID for webhook mapping
259
+ const garminUserId = await client.getUserId();
260
+ if (garminUserId) {
261
+ await ctx.runMutation(publicApi.public.updateConnection, {
262
+ connectionId,
263
+ providerUserId: garminUserId,
264
+ });
265
+ }
242
266
  const now = Math.floor(Date.now() / 1000);
243
267
  const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
244
268
  const timeRange = {
@@ -280,13 +304,7 @@ export const completeGarminOAuth = action({
280
304
  },
281
305
  returns: v.object({
282
306
  connectionId: v.string(),
283
- synced: v.object({
284
- activities: v.number(),
285
- dailies: v.number(),
286
- sleep: v.number(),
287
- body: v.number(),
288
- menstruation: v.number(),
289
- }),
307
+ synced: syncedValidator,
290
308
  errors: v.array(v.object({ type: v.string(), id: v.string(), error: v.string() })),
291
309
  }),
292
310
  handler: async (ctx, args) => {
@@ -321,6 +339,14 @@ export const completeGarminOAuth = action({
321
339
  const client = new GarminClient({
322
340
  accessToken: tokenResult.access_token,
323
341
  });
342
+ // Best-effort: resolve Garmin user ID for webhook mapping
343
+ const garminUserId = await client.getUserId();
344
+ if (garminUserId) {
345
+ await ctx.runMutation(publicApi.public.updateConnection, {
346
+ connectionId,
347
+ providerUserId: garminUserId,
348
+ });
349
+ }
324
350
  const now = Math.floor(Date.now() / 1000);
325
351
  const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
326
352
  const timeRange = {
@@ -358,13 +384,7 @@ export const syncGarmin = action({
358
384
  endTimeInSeconds: v.optional(v.number()),
359
385
  },
360
386
  returns: v.object({
361
- synced: v.object({
362
- activities: v.number(),
363
- dailies: v.number(),
364
- sleep: v.number(),
365
- body: v.number(),
366
- menstruation: v.number(),
367
- }),
387
+ synced: syncedValidator,
368
388
  errors: v.array(v.object({ type: v.string(), id: v.string(), error: v.string() })),
369
389
  }),
370
390
  handler: async (ctx, args) => {
@@ -405,6 +425,16 @@ export const syncGarmin = action({
405
425
  });
406
426
  }
407
427
  const client = new GarminClient({ accessToken });
428
+ // Lazy backfill: resolve Garmin user ID if missing (for webhook mapping)
429
+ if (!connection.providerUserId) {
430
+ const garminUserId = await client.getUserId();
431
+ if (garminUserId) {
432
+ await ctx.runMutation(publicApi.public.updateConnection, {
433
+ connectionId,
434
+ providerUserId: garminUserId,
435
+ });
436
+ }
437
+ }
408
438
  const now = Math.floor(Date.now() / 1000);
409
439
  const timeRange = {
410
440
  uploadStartTimeInSeconds: args.startTimeInSeconds ?? now - DEFAULT_SYNC_DAYS * 86400,
@@ -541,15 +571,572 @@ export const pushPlannedWorkout = action({
541
571
  const schedule = await client.createSchedule(created.workoutId, plannedDate);
542
572
  garminScheduleId = schedule.scheduleId ?? null;
543
573
  }
574
+ // Store the Garmin workout/schedule IDs back on the planned workout
575
+ // so the host app can match completed activities to planned sessions.
576
+ await ctx.runMutation(publicApi.public.ingestPlannedWorkout, {
577
+ ...plannedWorkout,
578
+ _id: undefined,
579
+ _creationTime: undefined,
580
+ metadata: {
581
+ ...plannedWorkout.metadata,
582
+ provider_workout_id: String(created.workoutId),
583
+ provider_schedule_id: garminScheduleId != null ? String(garminScheduleId) : undefined,
584
+ },
585
+ });
544
586
  return {
545
587
  garminWorkoutId: created.workoutId,
546
588
  garminScheduleId,
547
589
  };
548
590
  },
549
591
  });
592
+ // ─── Webhook Handlers (Push Mode) ────────────────────────────────────────────
593
+ // Each handler receives full Garmin data objects from push-mode webhooks.
594
+ // Separate actions per data type because the Garmin developer portal
595
+ // configures separate URLs per type.
596
+ const webhookResultValidator = v.object({
597
+ processed: v.number(),
598
+ errors: v.array(v.object({ type: v.string(), id: v.string(), error: v.string() })),
599
+ });
600
+ /**
601
+ * Handle a webhook for Garmin activities (push or ping mode).
602
+ *
603
+ * Push mode: receives full GarminActivity objects, transforms, and ingests.
604
+ * Ping mode: receives notifications, fetches data from the Garmin API, transforms, and ingests.
605
+ */
606
+ export const handleGarminWebhookActivities = action({
607
+ args: { payload: v.any() },
608
+ returns: webhookResultValidator,
609
+ handler: async (ctx, args) => {
610
+ return await processWebhookDualMode(ctx, {
611
+ items: args.payload,
612
+ type: "activity",
613
+ transform: (item) => transformActivity(item),
614
+ ingest: publicApi.public.ingestActivity,
615
+ getId: (item) => item.summaryId ??
616
+ String(item.activityId ?? "unknown"),
617
+ fetchData: (client, timeRange) => client.getActivities(timeRange),
618
+ });
619
+ },
620
+ });
621
+ /**
622
+ * Handle a webhook for Garmin daily summaries (push or ping mode).
623
+ */
624
+ export const handleGarminWebhookDailies = action({
625
+ args: { payload: v.any() },
626
+ returns: webhookResultValidator,
627
+ handler: async (ctx, args) => {
628
+ return await processWebhookDualMode(ctx, {
629
+ items: args.payload,
630
+ type: "daily",
631
+ transform: (item) => transformDaily(item),
632
+ ingest: publicApi.public.ingestDaily,
633
+ getId: (item) => item.summaryId ??
634
+ item.calendarDate ??
635
+ "unknown",
636
+ fetchData: (client, timeRange) => client.getDailies(timeRange),
637
+ });
638
+ },
639
+ });
640
+ /**
641
+ * Handle a webhook for Garmin sleep summaries (push or ping mode).
642
+ */
643
+ export const handleGarminWebhookSleeps = action({
644
+ args: { payload: v.any() },
645
+ returns: webhookResultValidator,
646
+ handler: async (ctx, args) => {
647
+ return await processWebhookDualMode(ctx, {
648
+ items: args.payload,
649
+ type: "sleep",
650
+ transform: (item) => transformSleep(item),
651
+ ingest: publicApi.public.ingestSleep,
652
+ getId: (item) => item.summaryId ??
653
+ item.calendarDate ??
654
+ "unknown",
655
+ fetchData: (client, timeRange) => client.getSleeps(timeRange),
656
+ });
657
+ },
658
+ });
659
+ /**
660
+ * Handle a webhook for Garmin body composition summaries (push or ping mode).
661
+ */
662
+ export const handleGarminWebhookBody = action({
663
+ args: { payload: v.any() },
664
+ returns: webhookResultValidator,
665
+ handler: async (ctx, args) => {
666
+ return await processWebhookDualMode(ctx, {
667
+ items: args.payload,
668
+ type: "body",
669
+ transform: (item) => transformBody(item),
670
+ ingest: publicApi.public.ingestBody,
671
+ getId: (item) => item
672
+ .summaryId ??
673
+ String(item
674
+ .measurementTimeInSeconds ?? "unknown"),
675
+ fetchData: (client, timeRange) => client.getBodyCompositions(timeRange),
676
+ });
677
+ },
678
+ });
679
+ /**
680
+ * Handle a webhook for Garmin menstrual cycle tracking (push or ping mode).
681
+ */
682
+ export const handleGarminWebhookMenstruation = action({
683
+ args: { payload: v.any() },
684
+ returns: webhookResultValidator,
685
+ handler: async (ctx, args) => {
686
+ return await processWebhookDualMode(ctx, {
687
+ items: args.payload,
688
+ type: "menstruation",
689
+ transform: (item) => transformMenstruation(item),
690
+ ingest: publicApi.public.ingestMenstruation,
691
+ getId: (item) => item.summaryId ??
692
+ item.calendarDate ??
693
+ "unknown",
694
+ fetchData: (client, timeRange) => client.getMenstrualCycleData(timeRange),
695
+ });
696
+ },
697
+ });
698
+ /**
699
+ * Handle a webhook for Garmin blood pressure summaries (push or ping mode).
700
+ */
701
+ export const handleGarminWebhookBloodPressures = action({
702
+ args: { payload: v.any() },
703
+ returns: webhookResultValidator,
704
+ handler: async (ctx, args) => {
705
+ return await processWebhookDualMode(ctx, {
706
+ items: args.payload,
707
+ type: "bloodPressure",
708
+ transform: (item) => transformBloodPressure(item),
709
+ ingest: publicApi.public.ingestBody,
710
+ getId: (item) => item
711
+ .summaryId ??
712
+ String(item
713
+ .measurementTimeInSeconds ?? "unknown"),
714
+ fetchData: (client, timeRange) => client.getBloodPressures(timeRange),
715
+ });
716
+ },
717
+ });
718
+ /**
719
+ * Handle a webhook for Garmin skin temperature summaries (push or ping mode).
720
+ */
721
+ export const handleGarminWebhookSkinTemp = action({
722
+ args: { payload: v.any() },
723
+ returns: webhookResultValidator,
724
+ handler: async (ctx, args) => {
725
+ return await processWebhookDualMode(ctx, {
726
+ items: args.payload,
727
+ type: "skinTemp",
728
+ transform: (item) => transformSkinTemp(item),
729
+ ingest: publicApi.public.ingestBody,
730
+ getId: (item) => item.summaryId ??
731
+ item.calendarDate ??
732
+ "unknown",
733
+ fetchData: (client, timeRange) => client.getSkinTemperature(timeRange),
734
+ });
735
+ },
736
+ });
737
+ /**
738
+ * Handle a webhook for Garmin user metrics (push or ping mode).
739
+ */
740
+ export const handleGarminWebhookUserMetrics = action({
741
+ args: { payload: v.any() },
742
+ returns: webhookResultValidator,
743
+ handler: async (ctx, args) => {
744
+ return await processWebhookDualMode(ctx, {
745
+ items: args.payload,
746
+ type: "userMetrics",
747
+ transform: (item) => transformUserMetrics(item),
748
+ ingest: publicApi.public.ingestBody,
749
+ getId: (item) => item.summaryId ??
750
+ item.calendarDate ??
751
+ "unknown",
752
+ fetchData: (client, timeRange) => client.getUserMetrics(timeRange),
753
+ });
754
+ },
755
+ });
756
+ /**
757
+ * Handle a webhook for Garmin HRV summaries (push or ping mode).
758
+ * Enriches daily records with heart_rate_data.
759
+ */
760
+ export const handleGarminWebhookHRV = action({
761
+ args: { payload: v.any() },
762
+ returns: webhookResultValidator,
763
+ handler: async (ctx, args) => {
764
+ return await processWebhookDualMode(ctx, {
765
+ items: args.payload,
766
+ type: "hrv",
767
+ transform: (item) => {
768
+ const record = item;
769
+ const data = transformHRV(item);
770
+ if (!data.heart_rate_data)
771
+ return null;
772
+ return {
773
+ metadata: {
774
+ start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
775
+ end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
776
+ upload_type: 1,
777
+ },
778
+ heart_rate_data: data.heart_rate_data,
779
+ };
780
+ },
781
+ ingest: publicApi.public.ingestDaily,
782
+ getId: (item) => item.summaryId ??
783
+ item.calendarDate ??
784
+ "unknown",
785
+ fetchData: (client, timeRange) => client.getHRV(timeRange),
786
+ });
787
+ },
788
+ });
789
+ /**
790
+ * Handle a webhook for Garmin stress detail summaries (push or ping mode).
791
+ * Enriches daily records with stress_data.
792
+ */
793
+ export const handleGarminWebhookStressDetails = action({
794
+ args: { payload: v.any() },
795
+ returns: webhookResultValidator,
796
+ handler: async (ctx, args) => {
797
+ return await processWebhookDualMode(ctx, {
798
+ items: args.payload,
799
+ type: "stressDetails",
800
+ transform: (item) => {
801
+ const record = item;
802
+ const data = transformStressDetails(item);
803
+ if (!data.stress_data)
804
+ return null;
805
+ return {
806
+ metadata: {
807
+ start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
808
+ end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
809
+ upload_type: 1,
810
+ },
811
+ stress_data: data.stress_data,
812
+ };
813
+ },
814
+ ingest: publicApi.public.ingestDaily,
815
+ getId: (item) => item.summaryId ??
816
+ item.calendarDate ??
817
+ "unknown",
818
+ fetchData: (client, timeRange) => client.getStressDetails(timeRange),
819
+ });
820
+ },
821
+ });
822
+ /**
823
+ * Handle a webhook for Garmin pulse oximetry (SpO2) summaries (push or ping mode).
824
+ * Enriches daily records with oxygen_data.
825
+ */
826
+ export const handleGarminWebhookPulseOx = action({
827
+ args: { payload: v.any() },
828
+ returns: webhookResultValidator,
829
+ handler: async (ctx, args) => {
830
+ return await processWebhookDualMode(ctx, {
831
+ items: args.payload,
832
+ type: "pulseOx",
833
+ transform: (item) => {
834
+ const record = item;
835
+ const data = transformPulseOx(item);
836
+ if (!data.oxygen_data)
837
+ return null;
838
+ return {
839
+ metadata: {
840
+ start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
841
+ end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
842
+ upload_type: 1,
843
+ },
844
+ oxygen_data: data.oxygen_data,
845
+ };
846
+ },
847
+ ingest: publicApi.public.ingestDaily,
848
+ getId: (item) => item.summaryId ??
849
+ item.calendarDate ??
850
+ "unknown",
851
+ fetchData: (client, timeRange) => client.getPulseOx(timeRange),
852
+ });
853
+ },
854
+ });
855
+ /**
856
+ * Handle a webhook for Garmin respiration summaries (push or ping mode).
857
+ * Enriches daily records with respiration_data.
858
+ */
859
+ export const handleGarminWebhookRespiration = action({
860
+ args: { payload: v.any() },
861
+ returns: webhookResultValidator,
862
+ handler: async (ctx, args) => {
863
+ return await processWebhookDualMode(ctx, {
864
+ items: args.payload,
865
+ type: "respiration",
866
+ transform: (item) => {
867
+ const record = item;
868
+ const data = transformRespiration(item);
869
+ if (!data.respiration_data)
870
+ return null;
871
+ return {
872
+ metadata: {
873
+ start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
874
+ end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
875
+ upload_type: 1,
876
+ },
877
+ respiration_data: data.respiration_data,
878
+ };
879
+ },
880
+ ingest: publicApi.public.ingestDaily,
881
+ getId: (item) => item.summaryId ?? "unknown",
882
+ fetchData: (client, timeRange) => client.getRespiration(timeRange),
883
+ });
884
+ },
885
+ });
886
+ async function processWebhookPayload(ctx, config) {
887
+ const { items, type, transform, ingest, getId } = config;
888
+ let processed = 0;
889
+ const errors = [];
890
+ if (!Array.isArray(items)) {
891
+ errors.push({ type, id: "payload", error: "Expected an array payload" });
892
+ return { processed, errors };
893
+ }
894
+ // Group items by Garmin userId
895
+ const byUser = new Map();
896
+ for (const item of items) {
897
+ const garminUserId = item.userId;
898
+ if (!garminUserId) {
899
+ errors.push({ type, id: getId(item), error: "Missing userId in payload item" });
900
+ continue;
901
+ }
902
+ if (!byUser.has(garminUserId))
903
+ byUser.set(garminUserId, []);
904
+ byUser.get(garminUserId).push(item);
905
+ }
906
+ // Process each Garmin user's items
907
+ for (const [garminUserId, userItems] of byUser) {
908
+ const connection = await ctx.runQuery(internalApi.private.getConnectionByProviderUserId, { providerUserId: garminUserId, provider: "GARMIN" });
909
+ if (!connection) {
910
+ for (const item of userItems) {
911
+ errors.push({
912
+ type,
913
+ id: getId(item),
914
+ error: `No Soma connection found for Garmin userId "${garminUserId}"`,
915
+ });
916
+ }
917
+ continue;
918
+ }
919
+ if (!connection.active) {
920
+ for (const item of userItems) {
921
+ errors.push({
922
+ type,
923
+ id: getId(item),
924
+ error: `Garmin connection for userId "${garminUserId}" is inactive`,
925
+ });
926
+ }
927
+ continue;
928
+ }
929
+ const connectionId = connection._id;
930
+ const userId = connection.userId;
931
+ for (const item of userItems) {
932
+ try {
933
+ const data = transform(item);
934
+ if (data == null)
935
+ continue; // Skip items with no transformable data
936
+ await ctx.runMutation(ingest, {
937
+ connectionId,
938
+ userId,
939
+ ...data,
940
+ });
941
+ processed++;
942
+ }
943
+ catch (err) {
944
+ errors.push({
945
+ type,
946
+ id: getId(item),
947
+ error: err instanceof Error ? err.message : String(err),
948
+ });
949
+ }
950
+ }
951
+ // Update last data timestamp
952
+ await ctx.runMutation(publicApi.public.updateConnection, {
953
+ connectionId,
954
+ lastDataUpdate: new Date().toISOString(),
955
+ });
956
+ }
957
+ return { processed, errors };
958
+ }
959
+ // ─── Ping Mode Support ───────────────────────────────────────────────────────
960
+ // In ping mode, Garmin sends a lightweight notification instead of full data.
961
+ // The handler fetches the actual data from the Garmin API using stored tokens.
962
+ /**
963
+ * Detect whether a webhook payload is ping mode (notification only) vs push
964
+ * mode (full data). Ping payloads have `uploadStartTimeInSeconds` and very
965
+ * few keys; push payloads contain the full summary object.
966
+ */
967
+ function isPingMode(items) {
968
+ if (items.length === 0)
969
+ return false;
970
+ const item = items[0];
971
+ return ("uploadStartTimeInSeconds" in item &&
972
+ "uploadEndTimeInSeconds" in item &&
973
+ Object.keys(item).length <= 6);
974
+ }
975
+ /**
976
+ * Process a webhook payload in either push or ping mode.
977
+ *
978
+ * - Push mode: items contain full data → transform and ingest directly.
979
+ * - Ping mode: items are notifications → fetch data from the API, then
980
+ * transform and ingest.
981
+ */
982
+ async function processWebhookDualMode(ctx, config) {
983
+ const mode = isPingMode(config.items) ? "ping" : "push";
984
+ console.log(`[garmin:webhook:${config.type}] mode=${mode} items=${config.items.length} payload:`, JSON.stringify(config.items, null, 2));
985
+ if (mode === "ping") {
986
+ return await processWebhookPingPayload(ctx, config);
987
+ }
988
+ return await processWebhookPayload(ctx, config);
989
+ }
990
+ async function processWebhookPingPayload(ctx, config) {
991
+ const { items, type, fetchData, transform, ingest, getId } = config;
992
+ let processed = 0;
993
+ const errors = [];
994
+ if (!Array.isArray(items)) {
995
+ errors.push({ type, id: "payload", error: "Expected an array payload" });
996
+ return { processed, errors };
997
+ }
998
+ // Group by Garmin userId and merge time ranges
999
+ const byUser = new Map();
1000
+ for (const item of items) {
1001
+ const garminUserId = item.userId;
1002
+ if (!garminUserId) {
1003
+ errors.push({ type, id: "unknown", error: "Missing userId in ping notification" });
1004
+ continue;
1005
+ }
1006
+ const existing = byUser.get(garminUserId);
1007
+ const start = item.uploadStartTimeInSeconds ?? 0;
1008
+ const end = item.uploadEndTimeInSeconds ?? 0;
1009
+ const token = item.userAccessToken;
1010
+ if (existing) {
1011
+ existing.minStart = Math.min(existing.minStart, start);
1012
+ existing.maxEnd = Math.max(existing.maxEnd, end);
1013
+ if (token && !existing.userAccessToken)
1014
+ existing.userAccessToken = token;
1015
+ }
1016
+ else {
1017
+ byUser.set(garminUserId, { userAccessToken: token, minStart: start, maxEnd: end });
1018
+ }
1019
+ }
1020
+ for (const [garminUserId, notification] of byUser) {
1021
+ const connection = await ctx.runQuery(internalApi.private.getConnectionByProviderUserId, { providerUserId: garminUserId, provider: "GARMIN" });
1022
+ if (!connection) {
1023
+ errors.push({
1024
+ type,
1025
+ id: "ping",
1026
+ error: `No Soma connection found for Garmin userId "${garminUserId}"`,
1027
+ });
1028
+ continue;
1029
+ }
1030
+ if (!connection.active) {
1031
+ errors.push({
1032
+ type,
1033
+ id: "ping",
1034
+ error: `Garmin connection for userId "${garminUserId}" is inactive`,
1035
+ });
1036
+ continue;
1037
+ }
1038
+ const connectionId = connection._id;
1039
+ const userId = connection.userId;
1040
+ // Resolve a valid access token: prefer stored (with refresh), fall back to notification
1041
+ let accessToken = null;
1042
+ const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
1043
+ connectionId,
1044
+ });
1045
+ if (tokenDoc) {
1046
+ accessToken = tokenDoc.accessToken;
1047
+ const nowSeconds = Math.floor(Date.now() / 1000);
1048
+ if (tokenDoc.expiresAt &&
1049
+ tokenDoc.refreshToken &&
1050
+ nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS) {
1051
+ const clientId = process.env.GARMIN_CLIENT_ID;
1052
+ const clientSecret = process.env.GARMIN_CLIENT_SECRET;
1053
+ if (clientId && clientSecret) {
1054
+ try {
1055
+ const refreshed = await refreshToken({
1056
+ clientId,
1057
+ clientSecret,
1058
+ refreshToken: tokenDoc.refreshToken,
1059
+ });
1060
+ accessToken = refreshed.access_token;
1061
+ await ctx.runMutation(internalApi.garmin.storeTokens, {
1062
+ connectionId,
1063
+ accessToken: refreshed.access_token,
1064
+ refreshToken: refreshed.refresh_token,
1065
+ expiresAt: nowSeconds + refreshed.expires_in,
1066
+ });
1067
+ }
1068
+ catch {
1069
+ // Refresh failed — fall through to notification token
1070
+ }
1071
+ }
1072
+ }
1073
+ }
1074
+ if (!accessToken) {
1075
+ accessToken = notification.userAccessToken ?? null;
1076
+ }
1077
+ if (!accessToken) {
1078
+ errors.push({
1079
+ type,
1080
+ id: "ping",
1081
+ error: `No access token available for Garmin userId "${garminUserId}"`,
1082
+ });
1083
+ continue;
1084
+ }
1085
+ const client = new GarminClient({ accessToken });
1086
+ // Use the merged time range from all notifications for this user
1087
+ let { minStart, maxEnd } = notification;
1088
+ if (minStart === 0 && maxEnd === 0) {
1089
+ const now = Math.floor(Date.now() / 1000);
1090
+ minStart = now - 86400;
1091
+ maxEnd = now;
1092
+ }
1093
+ try {
1094
+ const records = await fetchData(client, {
1095
+ uploadStartTimeInSeconds: minStart,
1096
+ uploadEndTimeInSeconds: maxEnd,
1097
+ });
1098
+ for (const record of records) {
1099
+ try {
1100
+ const data = transform(record);
1101
+ if (data == null)
1102
+ continue;
1103
+ await ctx.runMutation(ingest, {
1104
+ connectionId,
1105
+ userId,
1106
+ ...data,
1107
+ });
1108
+ processed++;
1109
+ }
1110
+ catch (err) {
1111
+ errors.push({
1112
+ type,
1113
+ id: getId(record),
1114
+ error: err instanceof Error ? err.message : String(err),
1115
+ });
1116
+ }
1117
+ }
1118
+ }
1119
+ catch (err) {
1120
+ errors.push({
1121
+ type,
1122
+ id: "fetch",
1123
+ error: err instanceof Error ? err.message : String(err),
1124
+ });
1125
+ }
1126
+ await ctx.runMutation(publicApi.public.updateConnection, {
1127
+ connectionId,
1128
+ lastDataUpdate: new Date().toISOString(),
1129
+ });
1130
+ }
1131
+ return { processed, errors };
1132
+ }
550
1133
  async function syncAllTypes(ctx, client, config) {
551
1134
  const { connectionId, userId, timeRange } = config;
552
- const synced = { activities: 0, dailies: 0, sleep: 0, body: 0, menstruation: 0 };
1135
+ const synced = {
1136
+ activities: 0, dailies: 0, sleep: 0, body: 0, menstruation: 0,
1137
+ bloodPressures: 0, skinTemp: 0, userMetrics: 0,
1138
+ hrv: 0, stressDetails: 0, pulseOx: 0, respiration: 0,
1139
+ };
553
1140
  const errors = [];
554
1141
  // ── Activities ──────────────────────────────────────────────────────────
555
1142
  try {
@@ -596,7 +1183,7 @@ async function syncAllTypes(ctx, client, config) {
596
1183
  catch (err) {
597
1184
  errors.push({
598
1185
  type: "daily",
599
- id: daily.summaryId ?? daily.calendarDate,
1186
+ id: daily.summaryId ?? daily.calendarDate ?? "unknown",
600
1187
  error: err instanceof Error ? err.message : String(err),
601
1188
  });
602
1189
  }
@@ -625,7 +1212,7 @@ async function syncAllTypes(ctx, client, config) {
625
1212
  catch (err) {
626
1213
  errors.push({
627
1214
  type: "sleep",
628
- id: sleep.summaryId ?? sleep.calendarDate,
1215
+ id: sleep.summaryId ?? sleep.calendarDate ?? "unknown",
629
1216
  error: err instanceof Error ? err.message : String(err),
630
1217
  });
631
1218
  }
@@ -683,7 +1270,7 @@ async function syncAllTypes(ctx, client, config) {
683
1270
  catch (err) {
684
1271
  errors.push({
685
1272
  type: "menstruation",
686
- id: record.summaryId ?? record.calendarDate,
1273
+ id: record.summaryId ?? record.periodStartDate ?? "unknown",
687
1274
  error: err instanceof Error ? err.message : String(err),
688
1275
  });
689
1276
  }
@@ -696,6 +1283,199 @@ async function syncAllTypes(ctx, client, config) {
696
1283
  error: err instanceof Error ? err.message : String(err),
697
1284
  });
698
1285
  }
1286
+ // ── Blood Pressures (→ body) ───────────────────────────────────────────
1287
+ try {
1288
+ const bpRecords = await client.getBloodPressures(timeRange);
1289
+ for (const bp of bpRecords) {
1290
+ try {
1291
+ const data = transformBloodPressure(bp);
1292
+ await ctx.runMutation(publicApi.public.ingestBody, {
1293
+ connectionId, userId, ...data,
1294
+ });
1295
+ synced.bloodPressures++;
1296
+ }
1297
+ catch (err) {
1298
+ errors.push({
1299
+ type: "bloodPressure",
1300
+ id: bp.summaryId ?? String(bp.measurementTimeInSeconds),
1301
+ error: err instanceof Error ? err.message : String(err),
1302
+ });
1303
+ }
1304
+ }
1305
+ }
1306
+ catch (err) {
1307
+ errors.push({ type: "bloodPressure", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1308
+ }
1309
+ // ── Skin Temperature (→ body) ──────────────────────────────────────────
1310
+ try {
1311
+ const skinRecords = await client.getSkinTemperature(timeRange);
1312
+ for (const skin of skinRecords) {
1313
+ try {
1314
+ const data = transformSkinTemp(skin);
1315
+ await ctx.runMutation(publicApi.public.ingestBody, {
1316
+ connectionId, userId, ...data,
1317
+ });
1318
+ synced.skinTemp++;
1319
+ }
1320
+ catch (err) {
1321
+ errors.push({
1322
+ type: "skinTemp",
1323
+ id: skin.summaryId ?? skin.calendarDate ?? "unknown",
1324
+ error: err instanceof Error ? err.message : String(err),
1325
+ });
1326
+ }
1327
+ }
1328
+ }
1329
+ catch (err) {
1330
+ errors.push({ type: "skinTemp", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1331
+ }
1332
+ // ── User Metrics (→ body) ──────────────────────────────────────────────
1333
+ try {
1334
+ const metricsRecords = await client.getUserMetrics(timeRange);
1335
+ for (const metrics of metricsRecords) {
1336
+ try {
1337
+ const data = transformUserMetrics(metrics);
1338
+ await ctx.runMutation(publicApi.public.ingestBody, {
1339
+ connectionId, userId, ...data,
1340
+ });
1341
+ synced.userMetrics++;
1342
+ }
1343
+ catch (err) {
1344
+ errors.push({
1345
+ type: "userMetrics",
1346
+ id: metrics.summaryId ?? metrics.calendarDate ?? "unknown",
1347
+ error: err instanceof Error ? err.message : String(err),
1348
+ });
1349
+ }
1350
+ }
1351
+ }
1352
+ catch (err) {
1353
+ errors.push({ type: "userMetrics", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1354
+ }
1355
+ // ── HRV (enriches daily) ──────────────────────────────────────────────
1356
+ try {
1357
+ const hrvRecords = await client.getHRV(timeRange);
1358
+ for (const hrv of hrvRecords) {
1359
+ try {
1360
+ const data = transformHRV(hrv);
1361
+ if (data.heart_rate_data) {
1362
+ await ctx.runMutation(publicApi.public.ingestDaily, {
1363
+ connectionId, userId,
1364
+ metadata: {
1365
+ start_time: new Date((hrv.startTimeInSeconds ?? 0) * 1000).toISOString(),
1366
+ end_time: new Date(((hrv.startTimeInSeconds ?? 0) + (hrv.durationInSeconds ?? 86400)) * 1000).toISOString(),
1367
+ upload_type: 1,
1368
+ },
1369
+ heart_rate_data: data.heart_rate_data,
1370
+ });
1371
+ synced.hrv++;
1372
+ }
1373
+ }
1374
+ catch (err) {
1375
+ errors.push({
1376
+ type: "hrv",
1377
+ id: hrv.summaryId ?? hrv.calendarDate ?? "unknown",
1378
+ error: err instanceof Error ? err.message : String(err),
1379
+ });
1380
+ }
1381
+ }
1382
+ }
1383
+ catch (err) {
1384
+ errors.push({ type: "hrv", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1385
+ }
1386
+ // ── Stress Details (enriches daily) ────────────────────────────────────
1387
+ try {
1388
+ const stressRecords = await client.getStressDetails(timeRange);
1389
+ for (const stress of stressRecords) {
1390
+ try {
1391
+ const data = transformStressDetails(stress);
1392
+ if (data.stress_data) {
1393
+ await ctx.runMutation(publicApi.public.ingestDaily, {
1394
+ connectionId, userId,
1395
+ metadata: {
1396
+ start_time: new Date((stress.startTimeInSeconds ?? 0) * 1000).toISOString(),
1397
+ end_time: new Date(((stress.startTimeInSeconds ?? 0) + (stress.durationInSeconds ?? 86400)) * 1000).toISOString(),
1398
+ upload_type: 1,
1399
+ },
1400
+ stress_data: data.stress_data,
1401
+ });
1402
+ synced.stressDetails++;
1403
+ }
1404
+ }
1405
+ catch (err) {
1406
+ errors.push({
1407
+ type: "stressDetails",
1408
+ id: stress.summaryId ?? stress.calendarDate ?? "unknown",
1409
+ error: err instanceof Error ? err.message : String(err),
1410
+ });
1411
+ }
1412
+ }
1413
+ }
1414
+ catch (err) {
1415
+ errors.push({ type: "stressDetails", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1416
+ }
1417
+ // ── Pulse Ox (enriches daily) ──────────────────────────────────────────
1418
+ try {
1419
+ const pulseOxRecords = await client.getPulseOx(timeRange);
1420
+ for (const po of pulseOxRecords) {
1421
+ try {
1422
+ const data = transformPulseOx(po);
1423
+ if (data.oxygen_data) {
1424
+ await ctx.runMutation(publicApi.public.ingestDaily, {
1425
+ connectionId, userId,
1426
+ metadata: {
1427
+ start_time: new Date((po.startTimeInSeconds ?? 0) * 1000).toISOString(),
1428
+ end_time: new Date(((po.startTimeInSeconds ?? 0) + (po.durationInSeconds ?? 86400)) * 1000).toISOString(),
1429
+ upload_type: 1,
1430
+ },
1431
+ oxygen_data: data.oxygen_data,
1432
+ });
1433
+ synced.pulseOx++;
1434
+ }
1435
+ }
1436
+ catch (err) {
1437
+ errors.push({
1438
+ type: "pulseOx",
1439
+ id: po.summaryId ?? po.calendarDate ?? "unknown",
1440
+ error: err instanceof Error ? err.message : String(err),
1441
+ });
1442
+ }
1443
+ }
1444
+ }
1445
+ catch (err) {
1446
+ errors.push({ type: "pulseOx", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1447
+ }
1448
+ // ── Respiration (enriches daily) ───────────────────────────────────────
1449
+ try {
1450
+ const respRecords = await client.getRespiration(timeRange);
1451
+ for (const resp of respRecords) {
1452
+ try {
1453
+ const data = transformRespiration(resp);
1454
+ if (data.respiration_data) {
1455
+ await ctx.runMutation(publicApi.public.ingestDaily, {
1456
+ connectionId, userId,
1457
+ metadata: {
1458
+ start_time: new Date((resp.startTimeInSeconds ?? 0) * 1000).toISOString(),
1459
+ end_time: new Date(((resp.startTimeInSeconds ?? 0) + (resp.durationInSeconds ?? 86400)) * 1000).toISOString(),
1460
+ upload_type: 1,
1461
+ },
1462
+ respiration_data: data.respiration_data,
1463
+ });
1464
+ synced.respiration++;
1465
+ }
1466
+ }
1467
+ catch (err) {
1468
+ errors.push({
1469
+ type: "respiration",
1470
+ id: resp.summaryId ?? "unknown",
1471
+ error: err instanceof Error ? err.message : String(err),
1472
+ });
1473
+ }
1474
+ }
1475
+ }
1476
+ catch (err) {
1477
+ errors.push({ type: "respiration", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1478
+ }
699
1479
  return { synced, errors };
700
1480
  }
701
1481
  //# sourceMappingURL=garmin.js.map