@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.
- package/dist/client/index.d.ts +83 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +131 -0
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +159 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +190 -6
- package/dist/component/garmin.d.ts.map +1 -1
- package/dist/component/garmin.js +805 -25
- package/dist/component/garmin.js.map +1 -1
- package/dist/component/private.d.ts +18 -0
- package/dist/component/private.d.ts.map +1 -1
- package/dist/component/private.js +18 -0
- package/dist/component/private.js.map +1 -1
- package/dist/component/public.d.ts +88 -42
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +12 -2
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +87 -32
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +2 -1
- package/dist/component/schema.js.map +1 -1
- package/dist/component/validators/connection.d.ts +1 -0
- package/dist/component/validators/connection.d.ts.map +1 -1
- package/dist/component/validators/connection.js +2 -0
- package/dist/component/validators/connection.js.map +1 -1
- package/dist/component/validators/daily.d.ts +40 -5
- package/dist/component/validators/daily.d.ts.map +1 -1
- package/dist/component/validators/daily.js +10 -1
- package/dist/component/validators/daily.js.map +1 -1
- package/dist/component/validators/enums.d.ts +1 -1
- package/dist/component/validators/plannedWorkout.d.ts +5 -1
- package/dist/component/validators/plannedWorkout.d.ts.map +1 -1
- package/dist/component/validators/plannedWorkout.js +4 -0
- package/dist/component/validators/plannedWorkout.js.map +1 -1
- package/dist/component/validators/sleep.d.ts +8 -8
- package/dist/garmin/activity.d.ts +7 -16
- package/dist/garmin/activity.d.ts.map +1 -1
- package/dist/garmin/activity.js +17 -23
- package/dist/garmin/activity.js.map +1 -1
- package/dist/garmin/bloodPressure.d.ts +28 -0
- package/dist/garmin/bloodPressure.d.ts.map +1 -0
- package/dist/garmin/bloodPressure.js +34 -0
- package/dist/garmin/bloodPressure.js.map +1 -0
- package/dist/garmin/body.js +1 -1
- package/dist/garmin/body.js.map +1 -1
- package/dist/garmin/client.d.ts +117 -17
- package/dist/garmin/client.d.ts.map +1 -1
- package/dist/garmin/client.js +337 -43
- package/dist/garmin/client.js.map +1 -1
- package/dist/garmin/daily.d.ts.map +1 -1
- package/dist/garmin/daily.js +3 -3
- package/dist/garmin/daily.js.map +1 -1
- package/dist/garmin/hrv.d.ts +30 -0
- package/dist/garmin/hrv.d.ts.map +1 -0
- package/dist/garmin/hrv.js +45 -0
- package/dist/garmin/hrv.js.map +1 -0
- package/dist/garmin/index.d.ts +16 -2
- package/dist/garmin/index.d.ts.map +1 -1
- package/dist/garmin/index.js +8 -1
- package/dist/garmin/index.js.map +1 -1
- package/dist/garmin/maps/activity-type.d.ts +1 -2
- package/dist/garmin/maps/activity-type.d.ts.map +1 -1
- package/dist/garmin/maps/activity-type.js +1 -0
- package/dist/garmin/maps/activity-type.js.map +1 -1
- package/dist/garmin/menstruation.d.ts +6 -4
- package/dist/garmin/menstruation.d.ts.map +1 -1
- package/dist/garmin/menstruation.js +12 -8
- package/dist/garmin/menstruation.js.map +1 -1
- package/dist/garmin/pulseOx.d.ts +24 -0
- package/dist/garmin/pulseOx.d.ts.map +1 -0
- package/dist/garmin/pulseOx.js +33 -0
- package/dist/garmin/pulseOx.js.map +1 -0
- package/dist/garmin/respiration.d.ts +29 -0
- package/dist/garmin/respiration.d.ts.map +1 -0
- package/dist/garmin/respiration.js +42 -0
- package/dist/garmin/respiration.js.map +1 -0
- package/dist/garmin/skinTemp.d.ts +27 -0
- package/dist/garmin/skinTemp.d.ts.map +1 -0
- package/dist/garmin/skinTemp.js +35 -0
- package/dist/garmin/skinTemp.js.map +1 -0
- package/dist/garmin/sleep.d.ts +4 -4
- package/dist/garmin/sleep.d.ts.map +1 -1
- package/dist/garmin/sleep.js +15 -9
- package/dist/garmin/sleep.js.map +1 -1
- package/dist/garmin/stressDetails.d.ts +30 -0
- package/dist/garmin/stressDetails.d.ts.map +1 -0
- package/dist/garmin/stressDetails.js +49 -0
- package/dist/garmin/stressDetails.js.map +1 -0
- package/dist/garmin/sync.d.ts +14 -0
- package/dist/garmin/sync.d.ts.map +1 -1
- package/dist/garmin/sync.js +287 -5
- package/dist/garmin/sync.js.map +1 -1
- package/dist/garmin/types.d.ts +77 -186
- package/dist/garmin/types.d.ts.map +1 -1
- package/dist/garmin/types.js +4 -2
- package/dist/garmin/types.js.map +1 -1
- package/dist/garmin/userMetrics.d.ts +23 -0
- package/dist/garmin/userMetrics.d.ts.map +1 -0
- package/dist/garmin/userMetrics.js +41 -0
- package/dist/garmin/userMetrics.js.map +1 -0
- package/dist/validators.d.ts +107 -28
- package/dist/validators.d.ts.map +1 -1
- package/package.json +133 -124
- package/src/client/index.ts +199 -0
- package/src/component/_generated/component.ts +161 -2
- package/src/component/garmin.ts +898 -26
- package/src/component/private.ts +21 -0
- package/src/component/public.ts +11 -2
- package/src/component/schema.ts +2 -1
- package/src/component/validators/connection.ts +2 -0
- package/src/component/validators/daily.ts +15 -0
- package/src/component/validators/plannedWorkout.ts +4 -0
- package/src/garmin/activity.test.ts +13 -21
- package/src/garmin/activity.ts +38 -45
- package/src/garmin/bloodPressure.ts +41 -0
- package/src/garmin/body.ts +1 -1
- package/src/garmin/client.ts +550 -71
- package/src/garmin/daily.ts +8 -4
- package/src/garmin/hrv.ts +57 -0
- package/src/garmin/index.ts +77 -7
- package/src/garmin/maps/activity-type.ts +2 -2
- package/src/garmin/menstruation.ts +14 -12
- package/src/garmin/pulseOx.ts +45 -0
- package/src/garmin/respiration.ts +55 -0
- package/src/garmin/skinTemp.ts +42 -0
- package/src/garmin/sleep.test.ts +5 -6
- package/src/garmin/sleep.ts +22 -16
- package/src/garmin/spec/wellness-api.json +1 -0
- package/src/garmin/stressDetails.ts +71 -0
- package/src/garmin/sync.ts +348 -5
- package/src/garmin/types.ts +88 -300
- package/src/garmin/userMetrics.ts +50 -0
- package/src/garmin/wellness-api.d.ts +5637 -0
package/src/component/garmin.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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 = {
|
|
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.
|
|
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
|
}
|