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