@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
@@ -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:
@@ -678,6 +712,20 @@ export const pushPlannedWorkout = action({
678
712
  garminScheduleId = schedule.scheduleId ?? null;
679
713
  }
680
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
+
681
729
  return {
682
730
  garminWorkoutId: created.workoutId,
683
731
  garminScheduleId,
@@ -685,6 +733,640 @@ export const pushPlannedWorkout = action({
685
733
  },
686
734
  });
687
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
+
688
1370
  // ─── Internal Helpers ────────────────────────────────────────────────────────
689
1371
 
690
1372
  interface SyncAllConfig {
@@ -694,7 +1376,7 @@ interface SyncAllConfig {
694
1376
  }
695
1377
 
696
1378
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
697
- 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> };
698
1380
 
699
1381
  async function syncAllTypes(
700
1382
  ctx: ActionContext,
@@ -703,7 +1385,11 @@ async function syncAllTypes(
703
1385
  ) {
704
1386
  const { connectionId, userId, timeRange } = config;
705
1387
 
706
- 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
+ };
707
1393
  const errors: Array<{ type: string; id: string; error: string }> = [];
708
1394
 
709
1395
  // ── Activities ──────────────────────────────────────────────────────────
@@ -749,7 +1435,7 @@ async function syncAllTypes(
749
1435
  } catch (err) {
750
1436
  errors.push({
751
1437
  type: "daily",
752
- id: daily.summaryId ?? daily.calendarDate,
1438
+ id: daily.summaryId ?? daily.calendarDate ?? "unknown",
753
1439
  error: err instanceof Error ? err.message : String(err),
754
1440
  });
755
1441
  }
@@ -777,7 +1463,7 @@ async function syncAllTypes(
777
1463
  } catch (err) {
778
1464
  errors.push({
779
1465
  type: "sleep",
780
- id: sleep.summaryId ?? sleep.calendarDate,
1466
+ id: sleep.summaryId ?? sleep.calendarDate ?? "unknown",
781
1467
  error: err instanceof Error ? err.message : String(err),
782
1468
  });
783
1469
  }
@@ -833,7 +1519,7 @@ async function syncAllTypes(
833
1519
  } catch (err) {
834
1520
  errors.push({
835
1521
  type: "menstruation",
836
- id: record.summaryId ?? record.calendarDate,
1522
+ id: record.summaryId ?? record.periodStartDate ?? "unknown",
837
1523
  error: err instanceof Error ? err.message : String(err),
838
1524
  });
839
1525
  }
@@ -846,5 +1532,191 @@ async function syncAllTypes(
846
1532
  });
847
1533
  }
848
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
+
849
1721
  return { synced, errors };
850
1722
  }