@nativesquare/soma 0.7.2 → 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 (133) 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 +829 -63
  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/plannedWorkout.d.ts +5 -1
  32. package/dist/component/validators/plannedWorkout.d.ts.map +1 -1
  33. package/dist/component/validators/plannedWorkout.js +4 -0
  34. package/dist/component/validators/plannedWorkout.js.map +1 -1
  35. package/dist/component/validators/sleep.d.ts +8 -8
  36. package/dist/garmin/activity.d.ts +7 -16
  37. package/dist/garmin/activity.d.ts.map +1 -1
  38. package/dist/garmin/activity.js +17 -23
  39. package/dist/garmin/activity.js.map +1 -1
  40. package/dist/garmin/bloodPressure.d.ts +28 -0
  41. package/dist/garmin/bloodPressure.d.ts.map +1 -0
  42. package/dist/garmin/bloodPressure.js +34 -0
  43. package/dist/garmin/bloodPressure.js.map +1 -0
  44. package/dist/garmin/body.js +1 -1
  45. package/dist/garmin/body.js.map +1 -1
  46. package/dist/garmin/client.d.ts +117 -17
  47. package/dist/garmin/client.d.ts.map +1 -1
  48. package/dist/garmin/client.js +337 -43
  49. package/dist/garmin/client.js.map +1 -1
  50. package/dist/garmin/daily.d.ts.map +1 -1
  51. package/dist/garmin/daily.js +3 -3
  52. package/dist/garmin/daily.js.map +1 -1
  53. package/dist/garmin/hrv.d.ts +30 -0
  54. package/dist/garmin/hrv.d.ts.map +1 -0
  55. package/dist/garmin/hrv.js +45 -0
  56. package/dist/garmin/hrv.js.map +1 -0
  57. package/dist/garmin/index.d.ts +16 -2
  58. package/dist/garmin/index.d.ts.map +1 -1
  59. package/dist/garmin/index.js +8 -1
  60. package/dist/garmin/index.js.map +1 -1
  61. package/dist/garmin/maps/activity-type.d.ts +1 -2
  62. package/dist/garmin/maps/activity-type.d.ts.map +1 -1
  63. package/dist/garmin/maps/activity-type.js +1 -0
  64. package/dist/garmin/maps/activity-type.js.map +1 -1
  65. package/dist/garmin/menstruation.d.ts +6 -4
  66. package/dist/garmin/menstruation.d.ts.map +1 -1
  67. package/dist/garmin/menstruation.js +12 -8
  68. package/dist/garmin/menstruation.js.map +1 -1
  69. package/dist/garmin/pulseOx.d.ts +24 -0
  70. package/dist/garmin/pulseOx.d.ts.map +1 -0
  71. package/dist/garmin/pulseOx.js +33 -0
  72. package/dist/garmin/pulseOx.js.map +1 -0
  73. package/dist/garmin/respiration.d.ts +29 -0
  74. package/dist/garmin/respiration.d.ts.map +1 -0
  75. package/dist/garmin/respiration.js +42 -0
  76. package/dist/garmin/respiration.js.map +1 -0
  77. package/dist/garmin/skinTemp.d.ts +27 -0
  78. package/dist/garmin/skinTemp.d.ts.map +1 -0
  79. package/dist/garmin/skinTemp.js +35 -0
  80. package/dist/garmin/skinTemp.js.map +1 -0
  81. package/dist/garmin/sleep.d.ts +4 -4
  82. package/dist/garmin/sleep.d.ts.map +1 -1
  83. package/dist/garmin/sleep.js +15 -9
  84. package/dist/garmin/sleep.js.map +1 -1
  85. package/dist/garmin/stressDetails.d.ts +30 -0
  86. package/dist/garmin/stressDetails.d.ts.map +1 -0
  87. package/dist/garmin/stressDetails.js +49 -0
  88. package/dist/garmin/stressDetails.js.map +1 -0
  89. package/dist/garmin/sync.d.ts +14 -0
  90. package/dist/garmin/sync.d.ts.map +1 -1
  91. package/dist/garmin/sync.js +287 -5
  92. package/dist/garmin/sync.js.map +1 -1
  93. package/dist/garmin/types.d.ts +77 -186
  94. package/dist/garmin/types.d.ts.map +1 -1
  95. package/dist/garmin/types.js +4 -2
  96. package/dist/garmin/types.js.map +1 -1
  97. package/dist/garmin/userMetrics.d.ts +23 -0
  98. package/dist/garmin/userMetrics.d.ts.map +1 -0
  99. package/dist/garmin/userMetrics.js +41 -0
  100. package/dist/garmin/userMetrics.js.map +1 -0
  101. package/dist/validators.d.ts +107 -28
  102. package/dist/validators.d.ts.map +1 -1
  103. package/package.json +133 -124
  104. package/src/client/index.ts +199 -0
  105. package/src/component/_generated/component.ts +161 -2
  106. package/src/component/garmin.ts +923 -73
  107. package/src/component/private.ts +21 -0
  108. package/src/component/public.ts +11 -2
  109. package/src/component/schema.ts +2 -1
  110. package/src/component/validators/connection.ts +2 -0
  111. package/src/component/validators/daily.ts +15 -0
  112. package/src/component/validators/plannedWorkout.ts +4 -0
  113. package/src/garmin/activity.test.ts +13 -21
  114. package/src/garmin/activity.ts +38 -45
  115. package/src/garmin/bloodPressure.ts +41 -0
  116. package/src/garmin/body.ts +1 -1
  117. package/src/garmin/client.ts +550 -71
  118. package/src/garmin/daily.ts +8 -4
  119. package/src/garmin/hrv.ts +57 -0
  120. package/src/garmin/index.ts +77 -7
  121. package/src/garmin/maps/activity-type.ts +2 -2
  122. package/src/garmin/menstruation.ts +14 -12
  123. package/src/garmin/pulseOx.ts +45 -0
  124. package/src/garmin/respiration.ts +55 -0
  125. package/src/garmin/skinTemp.ts +42 -0
  126. package/src/garmin/sleep.test.ts +5 -6
  127. package/src/garmin/sleep.ts +22 -16
  128. package/src/garmin/spec/wellness-api.json +1 -0
  129. package/src/garmin/stressDetails.ts +71 -0
  130. package/src/garmin/sync.ts +348 -5
  131. package/src/garmin/types.ts +88 -300
  132. package/src/garmin/userMetrics.ts +50 -0
  133. package/src/garmin/wellness-api.d.ts +5637 -0
@@ -26,6 +26,13 @@ import { transformDaily } from "../garmin/daily.js";
26
26
  import { transformSleep } from "../garmin/sleep.js";
27
27
  import { transformBody } from "../garmin/body.js";
28
28
  import { transformMenstruation } from "../garmin/menstruation.js";
29
+ import { transformBloodPressure } from "../garmin/bloodPressure.js";
30
+ import { transformSkinTemp } from "../garmin/skinTemp.js";
31
+ import { transformUserMetrics } from "../garmin/userMetrics.js";
32
+ import { transformHRV } from "../garmin/hrv.js";
33
+ import { transformStressDetails } from "../garmin/stressDetails.js";
34
+ import { transformPulseOx } from "../garmin/pulseOx.js";
35
+ import { transformRespiration } from "../garmin/respiration.js";
29
36
  import { transformPlannedWorkoutToGarmin } from "../garmin/plannedWorkout.js";
30
37
 
31
38
  // Use anyApi to avoid circular type references between this file and _generated/api.ts.
@@ -37,6 +44,22 @@ const internalApi: any = anyApi;
37
44
  // Default sync window: last 30 days
38
45
  const DEFAULT_SYNC_DAYS = 30;
39
46
 
47
+ // Shared synced counter validator for all sync actions
48
+ const syncedValidator = v.object({
49
+ activities: v.number(),
50
+ dailies: v.number(),
51
+ sleep: v.number(),
52
+ body: v.number(),
53
+ menstruation: v.number(),
54
+ bloodPressures: v.number(),
55
+ skinTemp: v.number(),
56
+ userMetrics: v.number(),
57
+ hrv: v.number(),
58
+ stressDetails: v.number(),
59
+ pulseOx: v.number(),
60
+ respiration: v.number(),
61
+ });
62
+
40
63
  // Refresh buffer: refresh tokens 10 minutes before expiry
41
64
  const REFRESH_BUFFER_SECONDS = 600;
42
65
 
@@ -252,13 +275,7 @@ export const connectGarmin = action({
252
275
  },
253
276
  returns: v.object({
254
277
  connectionId: v.string(),
255
- synced: v.object({
256
- activities: v.number(),
257
- dailies: v.number(),
258
- sleep: v.number(),
259
- body: v.number(),
260
- menstruation: v.number(),
261
- }),
278
+ synced: syncedValidator,
262
279
  errors: v.array(
263
280
  v.object({ type: v.string(), id: v.string(), error: v.string() }),
264
281
  ),
@@ -289,6 +306,15 @@ export const connectGarmin = action({
289
306
  accessToken: tokenResult.access_token,
290
307
  });
291
308
 
309
+ // Best-effort: resolve Garmin user ID for webhook mapping
310
+ const garminUserId = await client.getUserId();
311
+ if (garminUserId) {
312
+ await ctx.runMutation(publicApi.public.updateConnection, {
313
+ connectionId,
314
+ providerUserId: garminUserId,
315
+ });
316
+ }
317
+
292
318
  const now = Math.floor(Date.now() / 1000);
293
319
  const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
294
320
  const timeRange = {
@@ -334,13 +360,7 @@ export const completeGarminOAuth = action({
334
360
  },
335
361
  returns: v.object({
336
362
  connectionId: v.string(),
337
- synced: v.object({
338
- activities: v.number(),
339
- dailies: v.number(),
340
- sleep: v.number(),
341
- body: v.number(),
342
- menstruation: v.number(),
343
- }),
363
+ synced: syncedValidator,
344
364
  errors: v.array(
345
365
  v.object({ type: v.string(), id: v.string(), error: v.string() }),
346
366
  ),
@@ -385,6 +405,15 @@ export const completeGarminOAuth = action({
385
405
  accessToken: tokenResult.access_token,
386
406
  });
387
407
 
408
+ // Best-effort: resolve Garmin user ID for webhook mapping
409
+ const garminUserId = await client.getUserId();
410
+ if (garminUserId) {
411
+ await ctx.runMutation(publicApi.public.updateConnection, {
412
+ connectionId,
413
+ providerUserId: garminUserId,
414
+ });
415
+ }
416
+
388
417
  const now = Math.floor(Date.now() / 1000);
389
418
  const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
390
419
  const timeRange = {
@@ -426,13 +455,7 @@ export const syncGarmin = action({
426
455
  endTimeInSeconds: v.optional(v.number()),
427
456
  },
428
457
  returns: v.object({
429
- synced: v.object({
430
- activities: v.number(),
431
- dailies: v.number(),
432
- sleep: v.number(),
433
- body: v.number(),
434
- menstruation: v.number(),
435
- }),
458
+ synced: syncedValidator,
436
459
  errors: v.array(
437
460
  v.object({ type: v.string(), id: v.string(), error: v.string() }),
438
461
  ),
@@ -493,6 +516,17 @@ export const syncGarmin = action({
493
516
 
494
517
  const client = new GarminClient({ accessToken });
495
518
 
519
+ // Lazy backfill: resolve Garmin user ID if missing (for webhook mapping)
520
+ if (!connection.providerUserId) {
521
+ const garminUserId = await client.getUserId();
522
+ if (garminUserId) {
523
+ await ctx.runMutation(publicApi.public.updateConnection, {
524
+ connectionId,
525
+ providerUserId: garminUserId,
526
+ });
527
+ }
528
+ }
529
+
496
530
  const now = Math.floor(Date.now() / 1000);
497
531
  const timeRange = {
498
532
  uploadStartTimeInSeconds:
@@ -615,28 +649,33 @@ export const pushPlannedWorkout = action({
615
649
  );
616
650
  }
617
651
 
652
+ // Always force-refresh the token for Training API calls to rule out
653
+ // stale tokens (the initial sync swallows 401 errors silently).
618
654
  let accessToken = tokenDoc.accessToken;
619
655
 
620
- const nowSeconds = Math.floor(Date.now() / 1000);
621
- if (
622
- tokenDoc.expiresAt &&
623
- tokenDoc.refreshToken &&
624
- nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
625
- ) {
626
- const refreshed = await refreshToken({
627
- clientId: args.clientId,
628
- clientSecret: args.clientSecret,
629
- refreshToken: tokenDoc.refreshToken,
630
- });
656
+ if (tokenDoc.refreshToken) {
657
+ try {
658
+ const refreshed = await refreshToken({
659
+ clientId: args.clientId,
660
+ clientSecret: args.clientSecret,
661
+ refreshToken: tokenDoc.refreshToken,
662
+ });
631
663
 
632
- accessToken = refreshed.access_token;
633
- const newExpiresAt = nowSeconds + refreshed.expires_in;
634
- await ctx.runMutation(internalApi.garmin.storeTokens, {
635
- connectionId,
636
- accessToken: refreshed.access_token,
637
- refreshToken: refreshed.refresh_token,
638
- expiresAt: newExpiresAt,
639
- });
664
+ accessToken = refreshed.access_token;
665
+ const nowSeconds = Math.floor(Date.now() / 1000);
666
+ const newExpiresAt = nowSeconds + refreshed.expires_in;
667
+ await ctx.runMutation(internalApi.garmin.storeTokens, {
668
+ connectionId,
669
+ accessToken: refreshed.access_token,
670
+ refreshToken: refreshed.refresh_token,
671
+ expiresAt: newExpiresAt,
672
+ });
673
+ } catch (refreshErr) {
674
+ throw new Error(
675
+ `Garmin token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}. ` +
676
+ "The user may need to reconnect their Garmin account.",
677
+ );
678
+ }
640
679
  }
641
680
 
642
681
  const plannedWorkout = await ctx.runQuery(
@@ -649,40 +688,13 @@ export const pushPlannedWorkout = action({
649
688
  );
650
689
  }
651
690
 
652
- const client = new GarminClient({ accessToken });
653
-
654
- // Verify the user has WORKOUT_IMPORT permission before attempting to create
655
- let permissions: string[] = [];
656
- try {
657
- permissions = await client.getUserPermissions();
658
- } catch (permErr) {
659
- const status =
660
- permErr instanceof Error && "status" in permErr
661
- ? (permErr as { status: number }).status
662
- : undefined;
663
- throw new Error(
664
- `Unable to verify Garmin Training API permissions (HTTP ${status ?? "unknown"}). ` +
665
- "Ensure your Garmin app is registered for the Training API program " +
666
- "(https://developerportal.garmin.com/user/me/apps?program=829) " +
667
- "and that the user has completed OAuth with the correct scopes.",
668
- );
669
- }
670
-
671
- if (!permissions.includes("WORKOUT_IMPORT")) {
672
- throw new Error(
673
- `Garmin user does not have WORKOUT_IMPORT permission. ` +
674
- `Current permissions: [${permissions.join(", ")}]. ` +
675
- "The user must grant workout import access, or the Garmin app " +
676
- "may not be registered for the Training API program.",
677
- );
678
- }
679
-
680
691
  const providerName = args.workoutProvider ?? "Soma";
681
692
  const garminWorkout = transformPlannedWorkoutToGarmin(
682
693
  plannedWorkout,
683
694
  providerName,
684
695
  );
685
696
 
697
+ const client = new GarminClient({ accessToken });
686
698
  const created = await client.createWorkout(garminWorkout);
687
699
 
688
700
  if (!created.workoutId) {
@@ -700,6 +712,20 @@ export const pushPlannedWorkout = action({
700
712
  garminScheduleId = schedule.scheduleId ?? null;
701
713
  }
702
714
 
715
+ // Store the Garmin workout/schedule IDs back on the planned workout
716
+ // so the host app can match completed activities to planned sessions.
717
+ await ctx.runMutation(publicApi.public.ingestPlannedWorkout, {
718
+ ...plannedWorkout,
719
+ _id: undefined,
720
+ _creationTime: undefined,
721
+ metadata: {
722
+ ...plannedWorkout.metadata,
723
+ provider_workout_id: String(created.workoutId),
724
+ provider_schedule_id:
725
+ garminScheduleId != null ? String(garminScheduleId) : undefined,
726
+ },
727
+ } as never);
728
+
703
729
  return {
704
730
  garminWorkoutId: created.workoutId,
705
731
  garminScheduleId,
@@ -707,6 +733,640 @@ export const pushPlannedWorkout = action({
707
733
  },
708
734
  });
709
735
 
736
+ // ─── Webhook Handlers (Push Mode) ────────────────────────────────────────────
737
+ // Each handler receives full Garmin data objects from push-mode webhooks.
738
+ // Separate actions per data type because the Garmin developer portal
739
+ // configures separate URLs per type.
740
+
741
+ const webhookResultValidator = v.object({
742
+ processed: v.number(),
743
+ errors: v.array(
744
+ v.object({ type: v.string(), id: v.string(), error: v.string() }),
745
+ ),
746
+ });
747
+
748
+ /**
749
+ * Handle a webhook for Garmin activities (push or ping mode).
750
+ *
751
+ * Push mode: receives full GarminActivity objects, transforms, and ingests.
752
+ * Ping mode: receives notifications, fetches data from the Garmin API, transforms, and ingests.
753
+ */
754
+ export const handleGarminWebhookActivities = action({
755
+ args: { payload: v.any() },
756
+ returns: webhookResultValidator,
757
+ handler: async (ctx, args) => {
758
+ return await processWebhookDualMode(ctx, {
759
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
760
+ type: "activity",
761
+ transform: (item) => transformActivity(item as never),
762
+ ingest: publicApi.public.ingestActivity,
763
+ getId: (item) =>
764
+ (item as { summaryId?: string; activityId?: number }).summaryId ??
765
+ String((item as { activityId?: number }).activityId ?? "unknown"),
766
+ fetchData: (client, timeRange) => client.getActivities(timeRange),
767
+ });
768
+ },
769
+ });
770
+
771
+ /**
772
+ * Handle a webhook for Garmin daily summaries (push or ping mode).
773
+ */
774
+ export const handleGarminWebhookDailies = action({
775
+ args: { payload: v.any() },
776
+ returns: webhookResultValidator,
777
+ handler: async (ctx, args) => {
778
+ return await processWebhookDualMode(ctx, {
779
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
780
+ type: "daily",
781
+ transform: (item) => transformDaily(item as never),
782
+ ingest: publicApi.public.ingestDaily,
783
+ getId: (item) =>
784
+ (item as { summaryId?: string; calendarDate?: string }).summaryId ??
785
+ (item as { calendarDate?: string }).calendarDate ??
786
+ "unknown",
787
+ fetchData: (client, timeRange) => client.getDailies(timeRange),
788
+ });
789
+ },
790
+ });
791
+
792
+ /**
793
+ * Handle a webhook for Garmin sleep summaries (push or ping mode).
794
+ */
795
+ export const handleGarminWebhookSleeps = action({
796
+ args: { payload: v.any() },
797
+ returns: webhookResultValidator,
798
+ handler: async (ctx, args) => {
799
+ return await processWebhookDualMode(ctx, {
800
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
801
+ type: "sleep",
802
+ transform: (item) => transformSleep(item as never),
803
+ ingest: publicApi.public.ingestSleep,
804
+ getId: (item) =>
805
+ (item as { summaryId?: string; calendarDate?: string }).summaryId ??
806
+ (item as { calendarDate?: string }).calendarDate ??
807
+ "unknown",
808
+ fetchData: (client, timeRange) => client.getSleeps(timeRange),
809
+ });
810
+ },
811
+ });
812
+
813
+ /**
814
+ * Handle a webhook for Garmin body composition summaries (push or ping mode).
815
+ */
816
+ export const handleGarminWebhookBody = action({
817
+ args: { payload: v.any() },
818
+ returns: webhookResultValidator,
819
+ handler: async (ctx, args) => {
820
+ return await processWebhookDualMode(ctx, {
821
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
822
+ type: "body",
823
+ transform: (item) => transformBody(item as never),
824
+ ingest: publicApi.public.ingestBody,
825
+ getId: (item) =>
826
+ (item as { summaryId?: string; measurementTimeInSeconds?: number })
827
+ .summaryId ??
828
+ String(
829
+ (item as { measurementTimeInSeconds?: number })
830
+ .measurementTimeInSeconds ?? "unknown",
831
+ ),
832
+ fetchData: (client, timeRange) => client.getBodyCompositions(timeRange),
833
+ });
834
+ },
835
+ });
836
+
837
+ /**
838
+ * Handle a webhook for Garmin menstrual cycle tracking (push or ping mode).
839
+ */
840
+ export const handleGarminWebhookMenstruation = action({
841
+ args: { payload: v.any() },
842
+ returns: webhookResultValidator,
843
+ handler: async (ctx, args) => {
844
+ return await processWebhookDualMode(ctx, {
845
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
846
+ type: "menstruation",
847
+ transform: (item) => transformMenstruation(item as never),
848
+ ingest: publicApi.public.ingestMenstruation,
849
+ getId: (item) =>
850
+ (item as { summaryId?: string; calendarDate?: string }).summaryId ??
851
+ (item as { calendarDate?: string }).calendarDate ??
852
+ "unknown",
853
+ fetchData: (client, timeRange) => client.getMenstrualCycleData(timeRange),
854
+ });
855
+ },
856
+ });
857
+
858
+ /**
859
+ * Handle a webhook for Garmin blood pressure summaries (push or ping mode).
860
+ */
861
+ export const handleGarminWebhookBloodPressures = action({
862
+ args: { payload: v.any() },
863
+ returns: webhookResultValidator,
864
+ handler: async (ctx, args) => {
865
+ return await processWebhookDualMode(ctx, {
866
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
867
+ type: "bloodPressure",
868
+ transform: (item) => transformBloodPressure(item as never),
869
+ ingest: publicApi.public.ingestBody,
870
+ getId: (item) =>
871
+ (item as { summaryId?: string; measurementTimeInSeconds?: number })
872
+ .summaryId ??
873
+ String(
874
+ (item as { measurementTimeInSeconds?: number })
875
+ .measurementTimeInSeconds ?? "unknown",
876
+ ),
877
+ fetchData: (client, timeRange) => client.getBloodPressures(timeRange),
878
+ });
879
+ },
880
+ });
881
+
882
+ /**
883
+ * Handle a webhook for Garmin skin temperature summaries (push or ping mode).
884
+ */
885
+ export const handleGarminWebhookSkinTemp = action({
886
+ args: { payload: v.any() },
887
+ returns: webhookResultValidator,
888
+ handler: async (ctx, args) => {
889
+ return await processWebhookDualMode(ctx, {
890
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
891
+ type: "skinTemp",
892
+ transform: (item) => transformSkinTemp(item as never),
893
+ ingest: publicApi.public.ingestBody,
894
+ getId: (item) =>
895
+ (item as { summaryId?: string; calendarDate?: string }).summaryId ??
896
+ (item as { calendarDate?: string }).calendarDate ??
897
+ "unknown",
898
+ fetchData: (client, timeRange) => client.getSkinTemperature(timeRange),
899
+ });
900
+ },
901
+ });
902
+
903
+ /**
904
+ * Handle a webhook for Garmin user metrics (push or ping mode).
905
+ */
906
+ export const handleGarminWebhookUserMetrics = action({
907
+ args: { payload: v.any() },
908
+ returns: webhookResultValidator,
909
+ handler: async (ctx, args) => {
910
+ return await processWebhookDualMode(ctx, {
911
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
912
+ type: "userMetrics",
913
+ transform: (item) => transformUserMetrics(item as never),
914
+ ingest: publicApi.public.ingestBody,
915
+ getId: (item) =>
916
+ (item as { summaryId?: string; calendarDate?: string }).summaryId ??
917
+ (item as { calendarDate?: string }).calendarDate ??
918
+ "unknown",
919
+ fetchData: (client, timeRange) => client.getUserMetrics(timeRange),
920
+ });
921
+ },
922
+ });
923
+
924
+ /**
925
+ * Handle a webhook for Garmin HRV summaries (push or ping mode).
926
+ * Enriches daily records with heart_rate_data.
927
+ */
928
+ export const handleGarminWebhookHRV = action({
929
+ args: { payload: v.any() },
930
+ returns: webhookResultValidator,
931
+ handler: async (ctx, args) => {
932
+ return await processWebhookDualMode(ctx, {
933
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
934
+ type: "hrv",
935
+ transform: (item) => {
936
+ const record = item as { startTimeInSeconds?: number; durationInSeconds?: number };
937
+ const data = transformHRV(item as never);
938
+ if (!data.heart_rate_data) return null;
939
+ return {
940
+ metadata: {
941
+ start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
942
+ end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
943
+ upload_type: 1,
944
+ },
945
+ heart_rate_data: data.heart_rate_data,
946
+ };
947
+ },
948
+ ingest: publicApi.public.ingestDaily,
949
+ getId: (item) =>
950
+ (item as { summaryId?: string; calendarDate?: string }).summaryId ??
951
+ (item as { calendarDate?: string }).calendarDate ??
952
+ "unknown",
953
+ fetchData: (client, timeRange) => client.getHRV(timeRange),
954
+ });
955
+ },
956
+ });
957
+
958
+ /**
959
+ * Handle a webhook for Garmin stress detail summaries (push or ping mode).
960
+ * Enriches daily records with stress_data.
961
+ */
962
+ export const handleGarminWebhookStressDetails = action({
963
+ args: { payload: v.any() },
964
+ returns: webhookResultValidator,
965
+ handler: async (ctx, args) => {
966
+ return await processWebhookDualMode(ctx, {
967
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
968
+ type: "stressDetails",
969
+ transform: (item) => {
970
+ const record = item as { startTimeInSeconds?: number; durationInSeconds?: number };
971
+ const data = transformStressDetails(item as never);
972
+ if (!data.stress_data) return null;
973
+ return {
974
+ metadata: {
975
+ start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
976
+ end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
977
+ upload_type: 1,
978
+ },
979
+ stress_data: data.stress_data,
980
+ };
981
+ },
982
+ ingest: publicApi.public.ingestDaily,
983
+ getId: (item) =>
984
+ (item as { summaryId?: string; calendarDate?: string }).summaryId ??
985
+ (item as { calendarDate?: string }).calendarDate ??
986
+ "unknown",
987
+ fetchData: (client, timeRange) => client.getStressDetails(timeRange),
988
+ });
989
+ },
990
+ });
991
+
992
+ /**
993
+ * Handle a webhook for Garmin pulse oximetry (SpO2) summaries (push or ping mode).
994
+ * Enriches daily records with oxygen_data.
995
+ */
996
+ export const handleGarminWebhookPulseOx = action({
997
+ args: { payload: v.any() },
998
+ returns: webhookResultValidator,
999
+ handler: async (ctx, args) => {
1000
+ return await processWebhookDualMode(ctx, {
1001
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
1002
+ type: "pulseOx",
1003
+ transform: (item) => {
1004
+ const record = item as { startTimeInSeconds?: number; durationInSeconds?: number };
1005
+ const data = transformPulseOx(item as never);
1006
+ if (!data.oxygen_data) return null;
1007
+ return {
1008
+ metadata: {
1009
+ start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
1010
+ end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
1011
+ upload_type: 1,
1012
+ },
1013
+ oxygen_data: data.oxygen_data,
1014
+ };
1015
+ },
1016
+ ingest: publicApi.public.ingestDaily,
1017
+ getId: (item) =>
1018
+ (item as { summaryId?: string; calendarDate?: string }).summaryId ??
1019
+ (item as { calendarDate?: string }).calendarDate ??
1020
+ "unknown",
1021
+ fetchData: (client, timeRange) => client.getPulseOx(timeRange),
1022
+ });
1023
+ },
1024
+ });
1025
+
1026
+ /**
1027
+ * Handle a webhook for Garmin respiration summaries (push or ping mode).
1028
+ * Enriches daily records with respiration_data.
1029
+ */
1030
+ export const handleGarminWebhookRespiration = action({
1031
+ args: { payload: v.any() },
1032
+ returns: webhookResultValidator,
1033
+ handler: async (ctx, args) => {
1034
+ return await processWebhookDualMode(ctx, {
1035
+ items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
1036
+ type: "respiration",
1037
+ transform: (item) => {
1038
+ const record = item as { startTimeInSeconds?: number; durationInSeconds?: number };
1039
+ const data = transformRespiration(item as never);
1040
+ if (!data.respiration_data) return null;
1041
+ return {
1042
+ metadata: {
1043
+ start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
1044
+ end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
1045
+ upload_type: 1,
1046
+ },
1047
+ respiration_data: data.respiration_data,
1048
+ };
1049
+ },
1050
+ ingest: publicApi.public.ingestDaily,
1051
+ getId: (item) =>
1052
+ (item as { summaryId?: string }).summaryId ?? "unknown",
1053
+ fetchData: (client, timeRange) => client.getRespiration(timeRange),
1054
+ });
1055
+ },
1056
+ });
1057
+
1058
+ // ─── Webhook Internal Helper ─────────────────────────────────────────────────
1059
+
1060
+ interface WebhookProcessConfig {
1061
+ items: Array<{ userId: string; [k: string]: unknown }>;
1062
+ type: string;
1063
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1064
+ transform: (item: unknown) => any;
1065
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1066
+ ingest: any;
1067
+ getId: (item: unknown) => string;
1068
+ }
1069
+
1070
+ async function processWebhookPayload(
1071
+ ctx: ActionContext,
1072
+ config: WebhookProcessConfig,
1073
+ ) {
1074
+ const { items, type, transform, ingest, getId } = config;
1075
+
1076
+ let processed = 0;
1077
+ const errors: Array<{ type: string; id: string; error: string }> = [];
1078
+
1079
+ if (!Array.isArray(items)) {
1080
+ errors.push({ type, id: "payload", error: "Expected an array payload" });
1081
+ return { processed, errors };
1082
+ }
1083
+
1084
+ // Group items by Garmin userId
1085
+ const byUser = new Map<string, Array<unknown>>();
1086
+ for (const item of items) {
1087
+ const garminUserId = item.userId;
1088
+ if (!garminUserId) {
1089
+ errors.push({ type, id: getId(item), error: "Missing userId in payload item" });
1090
+ continue;
1091
+ }
1092
+ if (!byUser.has(garminUserId)) byUser.set(garminUserId, []);
1093
+ byUser.get(garminUserId)!.push(item);
1094
+ }
1095
+
1096
+ // Process each Garmin user's items
1097
+ for (const [garminUserId, userItems] of byUser) {
1098
+ const connection = await ctx.runQuery(
1099
+ internalApi.private.getConnectionByProviderUserId,
1100
+ { providerUserId: garminUserId, provider: "GARMIN" },
1101
+ );
1102
+
1103
+ if (!connection) {
1104
+ for (const item of userItems) {
1105
+ errors.push({
1106
+ type,
1107
+ id: getId(item),
1108
+ error: `No Soma connection found for Garmin userId "${garminUserId}"`,
1109
+ });
1110
+ }
1111
+ continue;
1112
+ }
1113
+
1114
+ if (!connection.active) {
1115
+ for (const item of userItems) {
1116
+ errors.push({
1117
+ type,
1118
+ id: getId(item),
1119
+ error: `Garmin connection for userId "${garminUserId}" is inactive`,
1120
+ });
1121
+ }
1122
+ continue;
1123
+ }
1124
+
1125
+ const connectionId = connection._id;
1126
+ const userId = connection.userId;
1127
+
1128
+ for (const item of userItems) {
1129
+ try {
1130
+ const data = transform(item);
1131
+ if (data == null) continue; // Skip items with no transformable data
1132
+ await ctx.runMutation(ingest, {
1133
+ connectionId,
1134
+ userId,
1135
+ ...data,
1136
+ } as never);
1137
+ processed++;
1138
+ } catch (err) {
1139
+ errors.push({
1140
+ type,
1141
+ id: getId(item),
1142
+ error: err instanceof Error ? err.message : String(err),
1143
+ });
1144
+ }
1145
+ }
1146
+
1147
+ // Update last data timestamp
1148
+ await ctx.runMutation(publicApi.public.updateConnection, {
1149
+ connectionId,
1150
+ lastDataUpdate: new Date().toISOString(),
1151
+ });
1152
+ }
1153
+
1154
+ return { processed, errors };
1155
+ }
1156
+
1157
+ // ─── Ping Mode Support ───────────────────────────────────────────────────────
1158
+ // In ping mode, Garmin sends a lightweight notification instead of full data.
1159
+ // The handler fetches the actual data from the Garmin API using stored tokens.
1160
+
1161
+ /**
1162
+ * Detect whether a webhook payload is ping mode (notification only) vs push
1163
+ * mode (full data). Ping payloads have `uploadStartTimeInSeconds` and very
1164
+ * few keys; push payloads contain the full summary object.
1165
+ */
1166
+ function isPingMode(
1167
+ items: Array<{ [k: string]: unknown }>,
1168
+ ): boolean {
1169
+ if (items.length === 0) return false;
1170
+ const item = items[0];
1171
+ return (
1172
+ "uploadStartTimeInSeconds" in item &&
1173
+ "uploadEndTimeInSeconds" in item &&
1174
+ Object.keys(item).length <= 6
1175
+ );
1176
+ }
1177
+
1178
+ interface WebhookDualModeConfig extends WebhookProcessConfig {
1179
+ /** Fetch full records from the Garmin API (used in ping mode only). */
1180
+ fetchData: (
1181
+ client: GarminClient,
1182
+ timeRange: { uploadStartTimeInSeconds: number; uploadEndTimeInSeconds: number },
1183
+ ) => Promise<unknown[]>;
1184
+ }
1185
+
1186
+ /**
1187
+ * Process a webhook payload in either push or ping mode.
1188
+ *
1189
+ * - Push mode: items contain full data → transform and ingest directly.
1190
+ * - Ping mode: items are notifications → fetch data from the API, then
1191
+ * transform and ingest.
1192
+ */
1193
+ async function processWebhookDualMode(
1194
+ ctx: ActionContext,
1195
+ config: WebhookDualModeConfig,
1196
+ ) {
1197
+ const mode = isPingMode(config.items) ? "ping" : "push";
1198
+ console.log(
1199
+ `[garmin:webhook:${config.type}] mode=${mode} items=${config.items.length} payload:`,
1200
+ JSON.stringify(config.items, null, 2),
1201
+ );
1202
+ if (mode === "ping") {
1203
+ return await processWebhookPingPayload(ctx, config);
1204
+ }
1205
+ return await processWebhookPayload(ctx, config);
1206
+ }
1207
+
1208
+ async function processWebhookPingPayload(
1209
+ ctx: ActionContext,
1210
+ config: WebhookDualModeConfig,
1211
+ ) {
1212
+ const { items, type, fetchData, transform, ingest, getId } = config;
1213
+
1214
+ let processed = 0;
1215
+ const errors: Array<{ type: string; id: string; error: string }> = [];
1216
+
1217
+ if (!Array.isArray(items)) {
1218
+ errors.push({ type, id: "payload", error: "Expected an array payload" });
1219
+ return { processed, errors };
1220
+ }
1221
+
1222
+ // Group by Garmin userId and merge time ranges
1223
+ const byUser = new Map<
1224
+ string,
1225
+ { userAccessToken?: string; minStart: number; maxEnd: number }
1226
+ >();
1227
+ for (const item of items) {
1228
+ const garminUserId = (item as { userId?: string }).userId;
1229
+ if (!garminUserId) {
1230
+ errors.push({ type, id: "unknown", error: "Missing userId in ping notification" });
1231
+ continue;
1232
+ }
1233
+ const existing = byUser.get(garminUserId);
1234
+ const start = (item as { uploadStartTimeInSeconds?: number }).uploadStartTimeInSeconds ?? 0;
1235
+ const end = (item as { uploadEndTimeInSeconds?: number }).uploadEndTimeInSeconds ?? 0;
1236
+ const token = (item as { userAccessToken?: string }).userAccessToken;
1237
+ if (existing) {
1238
+ existing.minStart = Math.min(existing.minStart, start);
1239
+ existing.maxEnd = Math.max(existing.maxEnd, end);
1240
+ if (token && !existing.userAccessToken) existing.userAccessToken = token;
1241
+ } else {
1242
+ byUser.set(garminUserId, { userAccessToken: token, minStart: start, maxEnd: end });
1243
+ }
1244
+ }
1245
+
1246
+ for (const [garminUserId, notification] of byUser) {
1247
+ const connection = await ctx.runQuery(
1248
+ internalApi.private.getConnectionByProviderUserId,
1249
+ { providerUserId: garminUserId, provider: "GARMIN" },
1250
+ );
1251
+
1252
+ if (!connection) {
1253
+ errors.push({
1254
+ type,
1255
+ id: "ping",
1256
+ error: `No Soma connection found for Garmin userId "${garminUserId}"`,
1257
+ });
1258
+ continue;
1259
+ }
1260
+ if (!connection.active) {
1261
+ errors.push({
1262
+ type,
1263
+ id: "ping",
1264
+ error: `Garmin connection for userId "${garminUserId}" is inactive`,
1265
+ });
1266
+ continue;
1267
+ }
1268
+
1269
+ const connectionId = connection._id;
1270
+ const userId = connection.userId;
1271
+
1272
+ // Resolve a valid access token: prefer stored (with refresh), fall back to notification
1273
+ let accessToken: string | null = null;
1274
+ const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
1275
+ connectionId,
1276
+ });
1277
+ if (tokenDoc) {
1278
+ accessToken = tokenDoc.accessToken;
1279
+ const nowSeconds = Math.floor(Date.now() / 1000);
1280
+ if (
1281
+ tokenDoc.expiresAt &&
1282
+ tokenDoc.refreshToken &&
1283
+ nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
1284
+ ) {
1285
+ const clientId = process.env.GARMIN_CLIENT_ID;
1286
+ const clientSecret = process.env.GARMIN_CLIENT_SECRET;
1287
+ if (clientId && clientSecret) {
1288
+ try {
1289
+ const refreshed = await refreshToken({
1290
+ clientId,
1291
+ clientSecret,
1292
+ refreshToken: tokenDoc.refreshToken,
1293
+ });
1294
+ accessToken = refreshed.access_token;
1295
+ await ctx.runMutation(internalApi.garmin.storeTokens, {
1296
+ connectionId,
1297
+ accessToken: refreshed.access_token,
1298
+ refreshToken: refreshed.refresh_token,
1299
+ expiresAt: nowSeconds + refreshed.expires_in,
1300
+ });
1301
+ } catch {
1302
+ // Refresh failed — fall through to notification token
1303
+ }
1304
+ }
1305
+ }
1306
+ }
1307
+ if (!accessToken) {
1308
+ accessToken = notification.userAccessToken ?? null;
1309
+ }
1310
+ if (!accessToken) {
1311
+ errors.push({
1312
+ type,
1313
+ id: "ping",
1314
+ error: `No access token available for Garmin userId "${garminUserId}"`,
1315
+ });
1316
+ continue;
1317
+ }
1318
+
1319
+ const client = new GarminClient({ accessToken });
1320
+
1321
+ // Use the merged time range from all notifications for this user
1322
+ let { minStart, maxEnd } = notification;
1323
+ if (minStart === 0 && maxEnd === 0) {
1324
+ const now = Math.floor(Date.now() / 1000);
1325
+ minStart = now - 86400;
1326
+ maxEnd = now;
1327
+ }
1328
+
1329
+ try {
1330
+ const records = await fetchData(client, {
1331
+ uploadStartTimeInSeconds: minStart,
1332
+ uploadEndTimeInSeconds: maxEnd,
1333
+ });
1334
+
1335
+ for (const record of records) {
1336
+ try {
1337
+ const data = transform(record);
1338
+ if (data == null) continue;
1339
+ await ctx.runMutation(ingest, {
1340
+ connectionId,
1341
+ userId,
1342
+ ...data,
1343
+ } as never);
1344
+ processed++;
1345
+ } catch (err) {
1346
+ errors.push({
1347
+ type,
1348
+ id: getId(record),
1349
+ error: err instanceof Error ? err.message : String(err),
1350
+ });
1351
+ }
1352
+ }
1353
+ } catch (err) {
1354
+ errors.push({
1355
+ type,
1356
+ id: "fetch",
1357
+ error: err instanceof Error ? err.message : String(err),
1358
+ });
1359
+ }
1360
+
1361
+ await ctx.runMutation(publicApi.public.updateConnection, {
1362
+ connectionId,
1363
+ lastDataUpdate: new Date().toISOString(),
1364
+ });
1365
+ }
1366
+
1367
+ return { processed, errors };
1368
+ }
1369
+
710
1370
  // ─── Internal Helpers ────────────────────────────────────────────────────────
711
1371
 
712
1372
  interface SyncAllConfig {
@@ -716,7 +1376,7 @@ interface SyncAllConfig {
716
1376
  }
717
1377
 
718
1378
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
719
- type ActionContext = { runMutation: (ref: any, args: any) => Promise<any> };
1379
+ type ActionContext = { runMutation: (ref: any, args: any) => Promise<any>; runQuery: (ref: any, args: any) => Promise<any> };
720
1380
 
721
1381
  async function syncAllTypes(
722
1382
  ctx: ActionContext,
@@ -725,7 +1385,11 @@ async function syncAllTypes(
725
1385
  ) {
726
1386
  const { connectionId, userId, timeRange } = config;
727
1387
 
728
- const synced = { activities: 0, dailies: 0, sleep: 0, body: 0, menstruation: 0 };
1388
+ const synced = {
1389
+ activities: 0, dailies: 0, sleep: 0, body: 0, menstruation: 0,
1390
+ bloodPressures: 0, skinTemp: 0, userMetrics: 0,
1391
+ hrv: 0, stressDetails: 0, pulseOx: 0, respiration: 0,
1392
+ };
729
1393
  const errors: Array<{ type: string; id: string; error: string }> = [];
730
1394
 
731
1395
  // ── Activities ──────────────────────────────────────────────────────────
@@ -771,7 +1435,7 @@ async function syncAllTypes(
771
1435
  } catch (err) {
772
1436
  errors.push({
773
1437
  type: "daily",
774
- id: daily.summaryId ?? daily.calendarDate,
1438
+ id: daily.summaryId ?? daily.calendarDate ?? "unknown",
775
1439
  error: err instanceof Error ? err.message : String(err),
776
1440
  });
777
1441
  }
@@ -799,7 +1463,7 @@ async function syncAllTypes(
799
1463
  } catch (err) {
800
1464
  errors.push({
801
1465
  type: "sleep",
802
- id: sleep.summaryId ?? sleep.calendarDate,
1466
+ id: sleep.summaryId ?? sleep.calendarDate ?? "unknown",
803
1467
  error: err instanceof Error ? err.message : String(err),
804
1468
  });
805
1469
  }
@@ -855,7 +1519,7 @@ async function syncAllTypes(
855
1519
  } catch (err) {
856
1520
  errors.push({
857
1521
  type: "menstruation",
858
- id: record.summaryId ?? record.calendarDate,
1522
+ id: record.summaryId ?? record.periodStartDate ?? "unknown",
859
1523
  error: err instanceof Error ? err.message : String(err),
860
1524
  });
861
1525
  }
@@ -868,5 +1532,191 @@ async function syncAllTypes(
868
1532
  });
869
1533
  }
870
1534
 
1535
+ // ── Blood Pressures (→ body) ───────────────────────────────────────────
1536
+ try {
1537
+ const bpRecords = await client.getBloodPressures(timeRange);
1538
+ for (const bp of bpRecords) {
1539
+ try {
1540
+ const data = transformBloodPressure(bp);
1541
+ await ctx.runMutation(publicApi.public.ingestBody, {
1542
+ connectionId, userId, ...data,
1543
+ } as never);
1544
+ synced.bloodPressures++;
1545
+ } catch (err) {
1546
+ errors.push({
1547
+ type: "bloodPressure",
1548
+ id: bp.summaryId ?? String(bp.measurementTimeInSeconds),
1549
+ error: err instanceof Error ? err.message : String(err),
1550
+ });
1551
+ }
1552
+ }
1553
+ } catch (err) {
1554
+ errors.push({ type: "bloodPressure", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1555
+ }
1556
+
1557
+ // ── Skin Temperature (→ body) ──────────────────────────────────────────
1558
+ try {
1559
+ const skinRecords = await client.getSkinTemperature(timeRange);
1560
+ for (const skin of skinRecords) {
1561
+ try {
1562
+ const data = transformSkinTemp(skin);
1563
+ await ctx.runMutation(publicApi.public.ingestBody, {
1564
+ connectionId, userId, ...data,
1565
+ } as never);
1566
+ synced.skinTemp++;
1567
+ } catch (err) {
1568
+ errors.push({
1569
+ type: "skinTemp",
1570
+ id: skin.summaryId ?? skin.calendarDate ?? "unknown",
1571
+ error: err instanceof Error ? err.message : String(err),
1572
+ });
1573
+ }
1574
+ }
1575
+ } catch (err) {
1576
+ errors.push({ type: "skinTemp", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1577
+ }
1578
+
1579
+ // ── User Metrics (→ body) ──────────────────────────────────────────────
1580
+ try {
1581
+ const metricsRecords = await client.getUserMetrics(timeRange);
1582
+ for (const metrics of metricsRecords) {
1583
+ try {
1584
+ const data = transformUserMetrics(metrics);
1585
+ await ctx.runMutation(publicApi.public.ingestBody, {
1586
+ connectionId, userId, ...data,
1587
+ } as never);
1588
+ synced.userMetrics++;
1589
+ } catch (err) {
1590
+ errors.push({
1591
+ type: "userMetrics",
1592
+ id: metrics.summaryId ?? metrics.calendarDate ?? "unknown",
1593
+ error: err instanceof Error ? err.message : String(err),
1594
+ });
1595
+ }
1596
+ }
1597
+ } catch (err) {
1598
+ errors.push({ type: "userMetrics", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1599
+ }
1600
+
1601
+ // ── HRV (enriches daily) ──────────────────────────────────────────────
1602
+ try {
1603
+ const hrvRecords = await client.getHRV(timeRange);
1604
+ for (const hrv of hrvRecords) {
1605
+ try {
1606
+ const data = transformHRV(hrv);
1607
+ if (data.heart_rate_data) {
1608
+ await ctx.runMutation(publicApi.public.ingestDaily, {
1609
+ connectionId, userId,
1610
+ metadata: {
1611
+ start_time: new Date((hrv.startTimeInSeconds ?? 0) * 1000).toISOString(),
1612
+ end_time: new Date(((hrv.startTimeInSeconds ?? 0) + (hrv.durationInSeconds ?? 86400)) * 1000).toISOString(),
1613
+ upload_type: 1,
1614
+ },
1615
+ heart_rate_data: data.heart_rate_data,
1616
+ } as never);
1617
+ synced.hrv++;
1618
+ }
1619
+ } catch (err) {
1620
+ errors.push({
1621
+ type: "hrv",
1622
+ id: hrv.summaryId ?? hrv.calendarDate ?? "unknown",
1623
+ error: err instanceof Error ? err.message : String(err),
1624
+ });
1625
+ }
1626
+ }
1627
+ } catch (err) {
1628
+ errors.push({ type: "hrv", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1629
+ }
1630
+
1631
+ // ── Stress Details (enriches daily) ────────────────────────────────────
1632
+ try {
1633
+ const stressRecords = await client.getStressDetails(timeRange);
1634
+ for (const stress of stressRecords) {
1635
+ try {
1636
+ const data = transformStressDetails(stress);
1637
+ if (data.stress_data) {
1638
+ await ctx.runMutation(publicApi.public.ingestDaily, {
1639
+ connectionId, userId,
1640
+ metadata: {
1641
+ start_time: new Date((stress.startTimeInSeconds ?? 0) * 1000).toISOString(),
1642
+ end_time: new Date(((stress.startTimeInSeconds ?? 0) + (stress.durationInSeconds ?? 86400)) * 1000).toISOString(),
1643
+ upload_type: 1,
1644
+ },
1645
+ stress_data: data.stress_data,
1646
+ } as never);
1647
+ synced.stressDetails++;
1648
+ }
1649
+ } catch (err) {
1650
+ errors.push({
1651
+ type: "stressDetails",
1652
+ id: stress.summaryId ?? stress.calendarDate ?? "unknown",
1653
+ error: err instanceof Error ? err.message : String(err),
1654
+ });
1655
+ }
1656
+ }
1657
+ } catch (err) {
1658
+ errors.push({ type: "stressDetails", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1659
+ }
1660
+
1661
+ // ── Pulse Ox (enriches daily) ──────────────────────────────────────────
1662
+ try {
1663
+ const pulseOxRecords = await client.getPulseOx(timeRange);
1664
+ for (const po of pulseOxRecords) {
1665
+ try {
1666
+ const data = transformPulseOx(po);
1667
+ if (data.oxygen_data) {
1668
+ await ctx.runMutation(publicApi.public.ingestDaily, {
1669
+ connectionId, userId,
1670
+ metadata: {
1671
+ start_time: new Date((po.startTimeInSeconds ?? 0) * 1000).toISOString(),
1672
+ end_time: new Date(((po.startTimeInSeconds ?? 0) + (po.durationInSeconds ?? 86400)) * 1000).toISOString(),
1673
+ upload_type: 1,
1674
+ },
1675
+ oxygen_data: data.oxygen_data,
1676
+ } as never);
1677
+ synced.pulseOx++;
1678
+ }
1679
+ } catch (err) {
1680
+ errors.push({
1681
+ type: "pulseOx",
1682
+ id: po.summaryId ?? po.calendarDate ?? "unknown",
1683
+ error: err instanceof Error ? err.message : String(err),
1684
+ });
1685
+ }
1686
+ }
1687
+ } catch (err) {
1688
+ errors.push({ type: "pulseOx", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1689
+ }
1690
+
1691
+ // ── Respiration (enriches daily) ───────────────────────────────────────
1692
+ try {
1693
+ const respRecords = await client.getRespiration(timeRange);
1694
+ for (const resp of respRecords) {
1695
+ try {
1696
+ const data = transformRespiration(resp);
1697
+ if (data.respiration_data) {
1698
+ await ctx.runMutation(publicApi.public.ingestDaily, {
1699
+ connectionId, userId,
1700
+ metadata: {
1701
+ start_time: new Date((resp.startTimeInSeconds ?? 0) * 1000).toISOString(),
1702
+ end_time: new Date(((resp.startTimeInSeconds ?? 0) + (resp.durationInSeconds ?? 86400)) * 1000).toISOString(),
1703
+ upload_type: 1,
1704
+ },
1705
+ respiration_data: data.respiration_data,
1706
+ } as never);
1707
+ synced.respiration++;
1708
+ }
1709
+ } catch (err) {
1710
+ errors.push({
1711
+ type: "respiration",
1712
+ id: resp.summaryId ?? "unknown",
1713
+ error: err instanceof Error ? err.message : String(err),
1714
+ });
1715
+ }
1716
+ }
1717
+ } catch (err) {
1718
+ errors.push({ type: "respiration", id: "fetch", error: err instanceof Error ? err.message : String(err) });
1719
+ }
1720
+
871
1721
  return { synced, errors };
872
1722
  }