@nativesquare/soma 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +829 -63
- 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/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 +923 -73
- 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,
|
|
@@ -499,52 +529,38 @@ export const pushPlannedWorkout = action({
|
|
|
499
529
|
throw new Error("No Garmin tokens found for this connection. " +
|
|
500
530
|
"The connection may have been created before token storage was available.");
|
|
501
531
|
}
|
|
532
|
+
// Always force-refresh the token for Training API calls to rule out
|
|
533
|
+
// stale tokens (the initial sync swallows 401 errors silently).
|
|
502
534
|
let accessToken = tokenDoc.accessToken;
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
535
|
+
if (tokenDoc.refreshToken) {
|
|
536
|
+
try {
|
|
537
|
+
const refreshed = await refreshToken({
|
|
538
|
+
clientId: args.clientId,
|
|
539
|
+
clientSecret: args.clientSecret,
|
|
540
|
+
refreshToken: tokenDoc.refreshToken,
|
|
541
|
+
});
|
|
542
|
+
accessToken = refreshed.access_token;
|
|
543
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
544
|
+
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
545
|
+
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
546
|
+
connectionId,
|
|
547
|
+
accessToken: refreshed.access_token,
|
|
548
|
+
refreshToken: refreshed.refresh_token,
|
|
549
|
+
expiresAt: newExpiresAt,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
catch (refreshErr) {
|
|
553
|
+
throw new Error(`Garmin token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}. ` +
|
|
554
|
+
"The user may need to reconnect their Garmin account.");
|
|
555
|
+
}
|
|
520
556
|
}
|
|
521
557
|
const plannedWorkout = await ctx.runQuery(publicApi.public.getPlannedWorkout, { plannedWorkoutId: args.plannedWorkoutId });
|
|
522
558
|
if (!plannedWorkout) {
|
|
523
559
|
throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
|
|
524
560
|
}
|
|
525
|
-
const client = new GarminClient({ accessToken });
|
|
526
|
-
// Verify the user has WORKOUT_IMPORT permission before attempting to create
|
|
527
|
-
let permissions = [];
|
|
528
|
-
try {
|
|
529
|
-
permissions = await client.getUserPermissions();
|
|
530
|
-
}
|
|
531
|
-
catch (permErr) {
|
|
532
|
-
const status = permErr instanceof Error && "status" in permErr
|
|
533
|
-
? permErr.status
|
|
534
|
-
: undefined;
|
|
535
|
-
throw new Error(`Unable to verify Garmin Training API permissions (HTTP ${status ?? "unknown"}). ` +
|
|
536
|
-
"Ensure your Garmin app is registered for the Training API program " +
|
|
537
|
-
"(https://developerportal.garmin.com/user/me/apps?program=829) " +
|
|
538
|
-
"and that the user has completed OAuth with the correct scopes.");
|
|
539
|
-
}
|
|
540
|
-
if (!permissions.includes("WORKOUT_IMPORT")) {
|
|
541
|
-
throw new Error(`Garmin user does not have WORKOUT_IMPORT permission. ` +
|
|
542
|
-
`Current permissions: [${permissions.join(", ")}]. ` +
|
|
543
|
-
"The user must grant workout import access, or the Garmin app " +
|
|
544
|
-
"may not be registered for the Training API program.");
|
|
545
|
-
}
|
|
546
561
|
const providerName = args.workoutProvider ?? "Soma";
|
|
547
562
|
const garminWorkout = transformPlannedWorkoutToGarmin(plannedWorkout, providerName);
|
|
563
|
+
const client = new GarminClient({ accessToken });
|
|
548
564
|
const created = await client.createWorkout(garminWorkout);
|
|
549
565
|
if (!created.workoutId) {
|
|
550
566
|
throw new Error("Garmin API did not return a workoutId after creation.");
|
|
@@ -555,15 +571,572 @@ export const pushPlannedWorkout = action({
|
|
|
555
571
|
const schedule = await client.createSchedule(created.workoutId, plannedDate);
|
|
556
572
|
garminScheduleId = schedule.scheduleId ?? null;
|
|
557
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
|
+
});
|
|
558
586
|
return {
|
|
559
587
|
garminWorkoutId: created.workoutId,
|
|
560
588
|
garminScheduleId,
|
|
561
589
|
};
|
|
562
590
|
},
|
|
563
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
|
+
}
|
|
564
1133
|
async function syncAllTypes(ctx, client, config) {
|
|
565
1134
|
const { connectionId, userId, timeRange } = config;
|
|
566
|
-
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
|
+
};
|
|
567
1140
|
const errors = [];
|
|
568
1141
|
// ── Activities ──────────────────────────────────────────────────────────
|
|
569
1142
|
try {
|
|
@@ -610,7 +1183,7 @@ async function syncAllTypes(ctx, client, config) {
|
|
|
610
1183
|
catch (err) {
|
|
611
1184
|
errors.push({
|
|
612
1185
|
type: "daily",
|
|
613
|
-
id: daily.summaryId ?? daily.calendarDate,
|
|
1186
|
+
id: daily.summaryId ?? daily.calendarDate ?? "unknown",
|
|
614
1187
|
error: err instanceof Error ? err.message : String(err),
|
|
615
1188
|
});
|
|
616
1189
|
}
|
|
@@ -639,7 +1212,7 @@ async function syncAllTypes(ctx, client, config) {
|
|
|
639
1212
|
catch (err) {
|
|
640
1213
|
errors.push({
|
|
641
1214
|
type: "sleep",
|
|
642
|
-
id: sleep.summaryId ?? sleep.calendarDate,
|
|
1215
|
+
id: sleep.summaryId ?? sleep.calendarDate ?? "unknown",
|
|
643
1216
|
error: err instanceof Error ? err.message : String(err),
|
|
644
1217
|
});
|
|
645
1218
|
}
|
|
@@ -697,7 +1270,7 @@ async function syncAllTypes(ctx, client, config) {
|
|
|
697
1270
|
catch (err) {
|
|
698
1271
|
errors.push({
|
|
699
1272
|
type: "menstruation",
|
|
700
|
-
id: record.summaryId ?? record.
|
|
1273
|
+
id: record.summaryId ?? record.periodStartDate ?? "unknown",
|
|
701
1274
|
error: err instanceof Error ? err.message : String(err),
|
|
702
1275
|
});
|
|
703
1276
|
}
|
|
@@ -710,6 +1283,199 @@ async function syncAllTypes(ctx, client, config) {
|
|
|
710
1283
|
error: err instanceof Error ? err.message : String(err),
|
|
711
1284
|
});
|
|
712
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
|
+
}
|
|
713
1479
|
return { synced, errors };
|
|
714
1480
|
}
|
|
715
1481
|
//# sourceMappingURL=garmin.js.map
|