@nativesquare/soma 0.8.0 → 0.9.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 +7 -128
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +31 -112
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/api.d.ts +80 -4
- package/dist/component/_generated/api.d.ts.map +1 -1
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +135 -261
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin/private.d.ts +475 -0
- package/dist/component/garmin/private.d.ts.map +1 -0
- package/dist/component/garmin/private.js +1614 -0
- package/dist/component/garmin/private.js.map +1 -0
- package/dist/component/garmin/public.d.ts +155 -0
- package/dist/component/garmin/public.d.ts.map +1 -0
- package/dist/component/garmin/public.js +787 -0
- package/dist/component/garmin/public.js.map +1 -0
- package/dist/component/garmin/schemas/activity.d.ts +94 -0
- package/dist/component/garmin/schemas/activity.d.ts.map +1 -0
- package/dist/component/garmin/schemas/activity.js +27 -0
- package/dist/component/garmin/schemas/activity.js.map +1 -0
- package/dist/component/garmin/schemas/activityDetails.d.ts +146 -0
- package/dist/component/garmin/schemas/activityDetails.d.ts.map +1 -0
- package/dist/component/garmin/schemas/activityDetails.js +27 -0
- package/dist/component/garmin/schemas/activityDetails.js.map +1 -0
- package/dist/component/garmin/schemas/bloodPressure.d.ts +38 -0
- package/dist/component/garmin/schemas/bloodPressure.d.ts.map +1 -0
- package/dist/component/garmin/schemas/bloodPressure.js +27 -0
- package/dist/component/garmin/schemas/bloodPressure.js.map +1 -0
- package/dist/component/garmin/schemas/bodyCompositions.d.ts +42 -0
- package/dist/component/garmin/schemas/bodyCompositions.d.ts.map +1 -0
- package/dist/component/garmin/schemas/bodyCompositions.js +27 -0
- package/dist/component/garmin/schemas/bodyCompositions.js.map +1 -0
- package/dist/component/garmin/schemas/dailies.d.ts +98 -0
- package/dist/component/garmin/schemas/dailies.d.ts.map +1 -0
- package/dist/component/garmin/schemas/dailies.js +27 -0
- package/dist/component/garmin/schemas/dailies.js.map +1 -0
- package/dist/component/garmin/schemas/epochs.d.ts +54 -0
- package/dist/component/garmin/schemas/epochs.d.ts.map +1 -0
- package/dist/component/garmin/schemas/epochs.js +27 -0
- package/dist/component/garmin/schemas/epochs.js.map +1 -0
- package/dist/component/garmin/schemas/healthSnapshot.d.ts +48 -0
- package/dist/component/garmin/schemas/healthSnapshot.d.ts.map +1 -0
- package/dist/component/garmin/schemas/healthSnapshot.js +27 -0
- package/dist/component/garmin/schemas/healthSnapshot.js.map +1 -0
- package/dist/component/garmin/schemas/hrvSummary.d.ts +40 -0
- package/dist/component/garmin/schemas/hrvSummary.d.ts.map +1 -0
- package/dist/component/garmin/schemas/hrvSummary.js +27 -0
- package/dist/component/garmin/schemas/hrvSummary.js.map +1 -0
- package/dist/component/garmin/schemas/manuallyUpdatedActivities.d.ts +94 -0
- package/dist/component/garmin/schemas/manuallyUpdatedActivities.d.ts.map +1 -0
- package/dist/component/garmin/schemas/manuallyUpdatedActivities.js +27 -0
- package/dist/component/garmin/schemas/manuallyUpdatedActivities.js.map +1 -0
- package/dist/component/garmin/schemas/menstrualCycleTracking.d.ts +100 -0
- package/dist/component/garmin/schemas/menstrualCycleTracking.d.ts.map +1 -0
- package/dist/component/garmin/schemas/menstrualCycleTracking.js +28 -0
- package/dist/component/garmin/schemas/menstrualCycleTracking.js.map +1 -0
- package/dist/component/garmin/schemas/moveIQ.d.ts +40 -0
- package/dist/component/garmin/schemas/moveIQ.d.ts.map +1 -0
- package/dist/component/garmin/schemas/moveIQ.js +27 -0
- package/dist/component/garmin/schemas/moveIQ.js.map +1 -0
- package/dist/component/garmin/schemas/pulseOx.d.ts +38 -0
- package/dist/component/garmin/schemas/pulseOx.d.ts.map +1 -0
- package/dist/component/garmin/schemas/pulseOx.js +28 -0
- package/dist/component/garmin/schemas/pulseOx.js.map +1 -0
- package/dist/component/garmin/schemas/respiration.d.ts +34 -0
- package/dist/component/garmin/schemas/respiration.d.ts.map +1 -0
- package/dist/component/garmin/schemas/respiration.js +28 -0
- package/dist/component/garmin/schemas/respiration.js.map +1 -0
- package/dist/component/garmin/schemas/skinTemperature.d.ts +36 -0
- package/dist/component/garmin/schemas/skinTemperature.d.ts.map +1 -0
- package/dist/component/garmin/schemas/skinTemperature.js +28 -0
- package/dist/component/garmin/schemas/skinTemperature.js.map +1 -0
- package/dist/component/garmin/schemas/sleeps.d.ts +88 -0
- package/dist/component/garmin/schemas/sleeps.d.ts.map +1 -0
- package/dist/component/garmin/schemas/sleeps.js +27 -0
- package/dist/component/garmin/schemas/sleeps.js.map +1 -0
- package/dist/component/garmin/schemas/stress.d.ts +70 -0
- package/dist/component/garmin/schemas/stress.d.ts.map +1 -0
- package/dist/component/garmin/schemas/stress.js +28 -0
- package/dist/component/garmin/schemas/stress.js.map +1 -0
- package/dist/component/garmin/schemas/userMetrics.d.ts +36 -0
- package/dist/component/garmin/schemas/userMetrics.d.ts.map +1 -0
- package/dist/component/garmin/schemas/userMetrics.js +28 -0
- package/dist/component/garmin/schemas/userMetrics.js.map +1 -0
- package/dist/component/garmin/transform/activity.d.ts +13 -0
- package/dist/component/garmin/transform/activity.d.ts.map +1 -0
- package/dist/component/garmin/transform/activity.js +111 -0
- package/dist/component/garmin/transform/activity.js.map +1 -0
- package/dist/component/garmin/transform/activityDetails.d.ts +13 -0
- package/dist/component/garmin/transform/activityDetails.d.ts.map +1 -0
- package/dist/component/garmin/transform/activityDetails.js +173 -0
- package/dist/component/garmin/transform/activityDetails.js.map +1 -0
- package/dist/component/garmin/transform/bloodPressure.d.ts +12 -0
- package/dist/component/garmin/transform/bloodPressure.d.ts.map +1 -0
- package/dist/component/garmin/transform/bloodPressure.js +33 -0
- package/dist/component/garmin/transform/bloodPressure.js.map +1 -0
- package/dist/component/garmin/transform/bodyCompositions.d.ts +12 -0
- package/dist/component/garmin/transform/bodyCompositions.d.ts.map +1 -0
- package/dist/component/garmin/transform/bodyCompositions.js +42 -0
- package/dist/component/garmin/transform/bodyCompositions.js.map +1 -0
- package/dist/component/garmin/transform/dailies.d.ts +12 -0
- package/dist/component/garmin/transform/dailies.d.ts.map +1 -0
- package/dist/component/garmin/transform/dailies.js +132 -0
- package/dist/component/garmin/transform/dailies.js.map +1 -0
- package/dist/component/garmin/transform/epochs.d.ts +13 -0
- package/dist/component/garmin/transform/epochs.d.ts.map +1 -0
- package/dist/component/garmin/transform/epochs.js +76 -0
- package/dist/component/garmin/transform/epochs.js.map +1 -0
- package/dist/component/garmin/transform/healthSnapshot.d.ts +12 -0
- package/dist/component/garmin/transform/healthSnapshot.d.ts.map +1 -0
- package/dist/component/garmin/transform/healthSnapshot.js +111 -0
- package/dist/component/garmin/transform/healthSnapshot.js.map +1 -0
- package/dist/component/garmin/transform/hrvSummary.d.ts +12 -0
- package/dist/component/garmin/transform/hrvSummary.d.ts.map +1 -0
- package/dist/component/garmin/transform/hrvSummary.js +45 -0
- package/dist/component/garmin/transform/hrvSummary.js.map +1 -0
- package/dist/component/garmin/transform/manuallyUpdatedActivities.d.ts +11 -0
- package/dist/component/garmin/transform/manuallyUpdatedActivities.d.ts.map +1 -0
- package/dist/component/garmin/transform/manuallyUpdatedActivities.js +20 -0
- package/dist/component/garmin/transform/manuallyUpdatedActivities.js.map +1 -0
- package/dist/component/garmin/transform/menstrualCycleTracking.d.ts +10 -0
- package/dist/component/garmin/transform/menstrualCycleTracking.d.ts.map +1 -0
- package/dist/component/garmin/transform/menstrualCycleTracking.js +43 -0
- package/dist/component/garmin/transform/menstrualCycleTracking.js.map +1 -0
- package/dist/component/garmin/transform/moveIQ.d.ts +17 -0
- package/dist/component/garmin/transform/moveIQ.d.ts.map +1 -0
- package/dist/component/garmin/transform/moveIQ.js +41 -0
- package/dist/component/garmin/transform/moveIQ.js.map +1 -0
- package/dist/component/garmin/transform/pulseOx.d.ts +12 -0
- package/dist/component/garmin/transform/pulseOx.d.ts.map +1 -0
- package/dist/component/garmin/transform/pulseOx.js +46 -0
- package/dist/component/garmin/transform/pulseOx.js.map +1 -0
- package/dist/component/garmin/transform/respiration.d.ts +12 -0
- package/dist/component/garmin/transform/respiration.d.ts.map +1 -0
- package/dist/component/garmin/transform/respiration.js +54 -0
- package/dist/component/garmin/transform/respiration.js.map +1 -0
- package/dist/component/garmin/transform/skinTemperature.d.ts +12 -0
- package/dist/component/garmin/transform/skinTemperature.d.ts.map +1 -0
- package/dist/component/garmin/transform/skinTemperature.js +38 -0
- package/dist/component/garmin/transform/skinTemperature.js.map +1 -0
- package/dist/component/garmin/transform/sleeps.d.ts +55 -0
- package/dist/component/garmin/transform/sleeps.d.ts.map +1 -0
- package/dist/component/garmin/transform/sleeps.js +120 -0
- package/dist/component/garmin/transform/sleeps.js.map +1 -0
- package/dist/component/garmin/transform/stress.d.ts +12 -0
- package/dist/component/garmin/transform/stress.d.ts.map +1 -0
- package/dist/component/garmin/transform/stress.js +56 -0
- package/dist/component/garmin/transform/stress.js.map +1 -0
- package/dist/component/garmin/transform/userMetrics.d.ts +12 -0
- package/dist/component/garmin/transform/userMetrics.d.ts.map +1 -0
- package/dist/component/garmin/transform/userMetrics.js +48 -0
- package/dist/component/garmin/transform/userMetrics.js.map +1 -0
- package/dist/component/garmin/types/garmin.d.ts +21 -0
- package/dist/component/garmin/types/garmin.d.ts.map +1 -0
- package/dist/component/garmin/types/garmin.js +6 -0
- package/dist/component/garmin/types/garmin.js.map +1 -0
- package/dist/component/garmin/types/zod/zod.gen.d.ts +1319 -0
- package/dist/component/garmin/types/zod/zod.gen.d.ts.map +1 -0
- package/dist/component/garmin/types/zod/zod.gen.js +784 -0
- package/dist/component/garmin/types/zod/zod.gen.js.map +1 -0
- package/dist/component/garmin/webhooks.d.ts +141 -0
- package/dist/component/garmin/webhooks.d.ts.map +1 -0
- package/dist/component/garmin/webhooks.js +766 -0
- package/dist/component/garmin/webhooks.js.map +1 -0
- package/dist/component/private.d.ts +4 -4
- package/dist/component/public.d.ts +333 -333
- package/dist/component/schema.d.ts +133 -133
- package/dist/component/strava/private.d.ts +30 -0
- package/dist/component/strava/private.d.ts.map +1 -0
- package/dist/component/strava/private.js +71 -0
- package/dist/component/strava/private.js.map +1 -0
- package/dist/component/{strava.d.ts → strava/public.d.ts} +3 -31
- package/dist/component/strava/public.d.ts.map +1 -0
- package/dist/component/{strava.js → strava/public.js} +22 -101
- package/dist/component/strava/public.js.map +1 -0
- package/dist/component/validators/activity.d.ts +6 -0
- package/dist/component/validators/activity.d.ts.map +1 -1
- package/dist/component/validators/activity.js.map +1 -1
- package/dist/component/validators/body.d.ts +20 -14
- package/dist/component/validators/body.d.ts.map +1 -1
- package/dist/component/validators/body.js.map +1 -1
- package/dist/component/validators/daily.d.ts +6 -0
- package/dist/component/validators/daily.d.ts.map +1 -1
- package/dist/component/validators/daily.js.map +1 -1
- package/dist/component/validators/enums.d.ts +1 -1
- package/dist/component/validators/menstruation.d.ts +5 -0
- package/dist/component/validators/menstruation.d.ts.map +1 -1
- package/dist/component/validators/menstruation.js.map +1 -1
- package/dist/garmin/client.js.map +1 -1
- package/dist/garmin/index.d.ts +0 -2
- package/dist/garmin/index.d.ts.map +1 -1
- package/dist/garmin/index.js +0 -1
- package/dist/garmin/index.js.map +1 -1
- package/dist/garmin/sync.d.ts.map +1 -1
- package/dist/garmin/sync.js +3 -2
- package/dist/garmin/sync.js.map +1 -1
- package/dist/garmin/types.d.ts +1 -1
- package/dist/garmin/types.d.ts.map +1 -1
- package/dist/validators.d.ts +31 -28
- package/dist/validators.d.ts.map +1 -1
- package/dist/validators.js +2 -2
- package/dist/validators.js.map +1 -1
- package/package.json +4 -7
- package/src/client/index.ts +41 -172
- package/src/component/_generated/api.ts +96 -4
- package/src/component/_generated/component.ts +252 -284
- package/src/{garmin → component/garmin}/auth.ts +8 -1
- package/src/component/garmin/client.ts +39 -0
- package/src/component/garmin/private.ts +1798 -0
- package/src/component/garmin/public.ts +938 -0
- package/src/component/garmin/schemas/activity.ts +40 -0
- package/src/component/garmin/schemas/activityDetails.ts +45 -0
- package/src/component/garmin/schemas/bloodPressure.ts +38 -0
- package/src/component/garmin/schemas/bodyCompositions.ts +38 -0
- package/src/component/garmin/schemas/dailies.ts +38 -0
- package/src/component/garmin/schemas/epochs.ts +38 -0
- package/src/component/garmin/schemas/healthSnapshot.ts +38 -0
- package/src/component/garmin/schemas/hrvSummary.ts +38 -0
- package/src/component/garmin/schemas/manuallyUpdatedActivities.ts +49 -0
- package/src/component/garmin/schemas/menstrualCycleTracking.ts +39 -0
- package/src/component/garmin/schemas/moveIQ.ts +38 -0
- package/src/component/garmin/schemas/pulseOx.ts +39 -0
- package/src/component/garmin/schemas/respiration.ts +39 -0
- package/src/component/garmin/schemas/skinTemperature.ts +39 -0
- package/src/component/garmin/schemas/sleeps.ts +38 -0
- package/src/component/garmin/schemas/stress.ts +43 -0
- package/src/component/garmin/schemas/userMetrics.ts +39 -0
- package/src/component/garmin/transform/activity.ts +143 -0
- package/src/component/garmin/transform/activityDetails.ts +236 -0
- package/src/{garmin → component/garmin/transform}/bloodPressure.ts +39 -41
- package/src/component/garmin/transform/bodyCompositions.ts +51 -0
- package/src/component/garmin/transform/dailies.ts +179 -0
- package/src/component/garmin/transform/epochs.ts +94 -0
- package/src/component/garmin/transform/healthSnapshot.ts +152 -0
- package/src/component/garmin/transform/hrvSummary.ts +56 -0
- package/src/component/garmin/transform/manuallyUpdatedActivities.ts +27 -0
- package/src/{garmin/maps/activity-type.ts → component/garmin/transform/maps/activityType.ts} +116 -116
- package/src/{garmin/maps/sleep-level.ts → component/garmin/transform/maps/sleepLevel.ts} +22 -22
- package/src/component/garmin/transform/menstrualCycleTracking.ts +48 -0
- package/src/component/garmin/transform/moveIQ.ts +48 -0
- package/src/{garmin → component/garmin/transform}/plannedWorkout.ts +328 -333
- package/src/component/garmin/transform/pulseOx.ts +64 -0
- package/src/component/garmin/transform/respiration.ts +73 -0
- package/src/component/garmin/transform/skinTemperature.ts +44 -0
- package/src/component/garmin/transform/sleeps.ts +159 -0
- package/src/component/garmin/transform/stress.ts +78 -0
- package/src/component/garmin/transform/userMetrics.ts +56 -0
- package/src/component/garmin/types/specs/training-api-workouts.json +699 -0
- package/src/component/garmin/types/trainingApiWorkouts/client/client.gen.ts +290 -0
- package/src/component/garmin/types/trainingApiWorkouts/client/index.ts +25 -0
- package/src/component/garmin/types/trainingApiWorkouts/client/types.gen.ts +214 -0
- package/src/component/garmin/types/trainingApiWorkouts/client/utils.gen.ts +316 -0
- package/src/component/garmin/types/trainingApiWorkouts/client.gen.ts +16 -0
- package/src/component/garmin/types/trainingApiWorkouts/core/auth.gen.ts +41 -0
- package/src/component/garmin/types/trainingApiWorkouts/core/bodySerializer.gen.ts +82 -0
- package/src/component/garmin/types/trainingApiWorkouts/core/params.gen.ts +169 -0
- package/src/component/garmin/types/trainingApiWorkouts/core/pathSerializer.gen.ts +171 -0
- package/src/component/garmin/types/trainingApiWorkouts/core/queryKeySerializer.gen.ts +117 -0
- package/src/component/garmin/types/trainingApiWorkouts/core/serverSentEvents.gen.ts +243 -0
- package/src/component/garmin/types/trainingApiWorkouts/core/types.gen.ts +104 -0
- package/src/component/garmin/types/trainingApiWorkouts/core/utils.gen.ts +140 -0
- package/src/component/garmin/types/trainingApiWorkouts/index.ts +4 -0
- package/src/component/garmin/types/trainingApiWorkouts/sdk.gen.ts +126 -0
- package/src/component/garmin/types/trainingApiWorkouts/types.gen.ts +387 -0
- package/src/component/garmin/types/trainingApiWorkouts/zod.gen.ts +423 -0
- package/src/component/garmin/types/wellnessApi/client/client.gen.ts +290 -0
- package/src/component/garmin/types/wellnessApi/client/index.ts +25 -0
- package/src/component/garmin/types/wellnessApi/client/types.gen.ts +214 -0
- package/src/component/garmin/types/wellnessApi/client/utils.gen.ts +316 -0
- package/src/component/garmin/types/wellnessApi/client.gen.ts +16 -0
- package/src/component/garmin/types/wellnessApi/core/auth.gen.ts +41 -0
- package/src/component/garmin/types/wellnessApi/core/bodySerializer.gen.ts +82 -0
- package/src/component/garmin/types/wellnessApi/core/params.gen.ts +169 -0
- package/src/component/garmin/types/wellnessApi/core/pathSerializer.gen.ts +171 -0
- package/src/component/garmin/types/wellnessApi/core/queryKeySerializer.gen.ts +117 -0
- package/src/component/garmin/types/wellnessApi/core/serverSentEvents.gen.ts +243 -0
- package/src/component/garmin/types/wellnessApi/core/types.gen.ts +104 -0
- package/src/component/garmin/types/wellnessApi/core/utils.gen.ts +140 -0
- package/src/component/garmin/types/wellnessApi/index.ts +4 -0
- package/src/component/garmin/types/wellnessApi/sdk.gen.ts +207 -0
- package/src/component/garmin/types/wellnessApi/types.gen.ts +2942 -0
- package/src/component/garmin/types/wellnessApi/zod.gen.ts +878 -0
- package/src/component/garmin/utils.ts +25 -0
- package/src/component/garmin/webhooks.ts +852 -0
- package/src/component/strava/private.ts +89 -0
- package/src/component/{strava.ts → strava/public.ts} +341 -404
- package/src/component/validators/activity.ts +5 -0
- package/src/component/validators/body.ts +5 -0
- package/src/component/validators/daily.ts +5 -0
- package/src/component/validators/menstruation.ts +5 -1
- package/src/component/validators/plannedWorkout.ts +5 -0
- package/src/validators.ts +12 -2
- package/dist/component/garmin.d.ts +0 -366
- package/dist/component/garmin.d.ts.map +0 -1
- package/dist/component/garmin.js +0 -1481
- package/dist/component/garmin.js.map +0 -1
- package/dist/component/strava.d.ts.map +0 -1
- package/dist/component/strava.js.map +0 -1
- package/dist/garmin/activity.d.ts +0 -92
- package/dist/garmin/activity.d.ts.map +0 -1
- package/dist/garmin/activity.js +0 -201
- package/dist/garmin/activity.js.map +0 -1
- package/src/component/garmin.ts +0 -1722
- package/src/garmin/activity.test.ts +0 -170
- package/src/garmin/activity.ts +0 -265
- package/src/garmin/auth.test.ts +0 -103
- package/src/garmin/body.ts +0 -59
- package/src/garmin/client.ts +0 -886
- package/src/garmin/daily.ts +0 -215
- package/src/garmin/hrv.ts +0 -57
- package/src/garmin/index.ts +0 -145
- package/src/garmin/maps/activity-type.test.ts +0 -78
- package/src/garmin/menstruation.ts +0 -44
- package/src/garmin/pulseOx.ts +0 -45
- package/src/garmin/respiration.ts +0 -55
- package/src/garmin/skinTemp.ts +0 -42
- package/src/garmin/sleep.test.ts +0 -109
- package/src/garmin/sleep.ts +0 -176
- package/src/garmin/stressDetails.ts +0 -71
- package/src/garmin/sync.ts +0 -566
- package/src/garmin/types.ts +0 -268
- package/src/garmin/userMetrics.ts +0 -50
- package/src/garmin/wellness-api.d.ts +0 -5637
- /package/src/{garmin/spec → component/garmin/types/specs}/wellness-api.json +0 -0
package/src/component/garmin.ts
DELETED
|
@@ -1,1722 +0,0 @@
|
|
|
1
|
-
// ─── Garmin Component Actions ────────────────────────────────────────────────
|
|
2
|
-
// Public actions that handle the full Garmin OAuth 2.0 PKCE + sync lifecycle.
|
|
3
|
-
// The host app calls these through the Soma class, which threads the
|
|
4
|
-
// credentials automatically from env vars or constructor config.
|
|
5
|
-
//
|
|
6
|
-
// Internal mutations manage the providerTokens table (token CRUD).
|
|
7
|
-
|
|
8
|
-
import { v } from "convex/values";
|
|
9
|
-
import { anyApi } from "convex/server";
|
|
10
|
-
import {
|
|
11
|
-
action,
|
|
12
|
-
internalMutation,
|
|
13
|
-
internalQuery,
|
|
14
|
-
} from "./_generated/server.js";
|
|
15
|
-
import {
|
|
16
|
-
generateCodeVerifier,
|
|
17
|
-
generateCodeChallenge,
|
|
18
|
-
generateState,
|
|
19
|
-
buildAuthUrl,
|
|
20
|
-
exchangeCode,
|
|
21
|
-
refreshToken,
|
|
22
|
-
} from "../garmin/auth.js";
|
|
23
|
-
import { GarminClient } from "../garmin/client.js";
|
|
24
|
-
import { transformActivity } from "../garmin/activity.js";
|
|
25
|
-
import { transformDaily } from "../garmin/daily.js";
|
|
26
|
-
import { transformSleep } from "../garmin/sleep.js";
|
|
27
|
-
import { transformBody } from "../garmin/body.js";
|
|
28
|
-
import { transformMenstruation } from "../garmin/menstruation.js";
|
|
29
|
-
import { transformBloodPressure } from "../garmin/bloodPressure.js";
|
|
30
|
-
import { transformSkinTemp } from "../garmin/skinTemp.js";
|
|
31
|
-
import { transformUserMetrics } from "../garmin/userMetrics.js";
|
|
32
|
-
import { transformHRV } from "../garmin/hrv.js";
|
|
33
|
-
import { transformStressDetails } from "../garmin/stressDetails.js";
|
|
34
|
-
import { transformPulseOx } from "../garmin/pulseOx.js";
|
|
35
|
-
import { transformRespiration } from "../garmin/respiration.js";
|
|
36
|
-
import { transformPlannedWorkoutToGarmin } from "../garmin/plannedWorkout.js";
|
|
37
|
-
|
|
38
|
-
// Use anyApi to avoid circular type references between this file and _generated/api.ts.
|
|
39
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
40
|
-
const publicApi: any = anyApi;
|
|
41
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
-
const internalApi: any = anyApi;
|
|
43
|
-
|
|
44
|
-
// Default sync window: last 30 days
|
|
45
|
-
const DEFAULT_SYNC_DAYS = 30;
|
|
46
|
-
|
|
47
|
-
// Shared synced counter validator for all sync actions
|
|
48
|
-
const syncedValidator = v.object({
|
|
49
|
-
activities: v.number(),
|
|
50
|
-
dailies: v.number(),
|
|
51
|
-
sleep: v.number(),
|
|
52
|
-
body: v.number(),
|
|
53
|
-
menstruation: v.number(),
|
|
54
|
-
bloodPressures: v.number(),
|
|
55
|
-
skinTemp: v.number(),
|
|
56
|
-
userMetrics: v.number(),
|
|
57
|
-
hrv: v.number(),
|
|
58
|
-
stressDetails: v.number(),
|
|
59
|
-
pulseOx: v.number(),
|
|
60
|
-
respiration: v.number(),
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// Refresh buffer: refresh tokens 10 minutes before expiry
|
|
64
|
-
const REFRESH_BUFFER_SECONDS = 600;
|
|
65
|
-
|
|
66
|
-
// ─── Internal Pending OAuth CRUD ─────────────────────────────────────────────
|
|
67
|
-
// Temporary storage for in-progress Garmin OAuth 2.0 PKCE flows.
|
|
68
|
-
// Bridges getGarminAuthUrl and completeGarminOAuth.
|
|
69
|
-
|
|
70
|
-
export const storePendingOAuth = internalMutation({
|
|
71
|
-
args: {
|
|
72
|
-
provider: v.string(),
|
|
73
|
-
state: v.string(),
|
|
74
|
-
codeVerifier: v.string(),
|
|
75
|
-
userId: v.string(),
|
|
76
|
-
},
|
|
77
|
-
returns: v.null(),
|
|
78
|
-
handler: async (ctx, args) => {
|
|
79
|
-
await ctx.db.insert("pendingOAuth", {
|
|
80
|
-
...args,
|
|
81
|
-
createdAt: Date.now(),
|
|
82
|
-
});
|
|
83
|
-
return null;
|
|
84
|
-
},
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
export const getPendingOAuth = internalQuery({
|
|
88
|
-
args: { state: v.string() },
|
|
89
|
-
returns: v.union(
|
|
90
|
-
v.object({
|
|
91
|
-
_id: v.id("pendingOAuth"),
|
|
92
|
-
_creationTime: v.number(),
|
|
93
|
-
provider: v.string(),
|
|
94
|
-
state: v.string(),
|
|
95
|
-
codeVerifier: v.string(),
|
|
96
|
-
userId: v.string(),
|
|
97
|
-
createdAt: v.number(),
|
|
98
|
-
}),
|
|
99
|
-
v.null(),
|
|
100
|
-
),
|
|
101
|
-
handler: async (ctx, args) => {
|
|
102
|
-
return await ctx.db
|
|
103
|
-
.query("pendingOAuth")
|
|
104
|
-
.withIndex("by_state", (q) => q.eq("state", args.state))
|
|
105
|
-
.first();
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
export const deletePendingOAuth = internalMutation({
|
|
110
|
-
args: { state: v.string() },
|
|
111
|
-
returns: v.null(),
|
|
112
|
-
handler: async (ctx, args) => {
|
|
113
|
-
const pending = await ctx.db
|
|
114
|
-
.query("pendingOAuth")
|
|
115
|
-
.withIndex("by_state", (q) => q.eq("state", args.state))
|
|
116
|
-
.first();
|
|
117
|
-
if (pending) {
|
|
118
|
-
await ctx.db.delete(pending._id);
|
|
119
|
-
}
|
|
120
|
-
return null;
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
// ─── Internal Token CRUD ─────────────────────────────────────────────────────
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Store OAuth 2.0 tokens for a Garmin connection.
|
|
128
|
-
* Upserts by connectionId — one token record per connection.
|
|
129
|
-
*/
|
|
130
|
-
export const storeTokens = internalMutation({
|
|
131
|
-
args: {
|
|
132
|
-
connectionId: v.id("connections"),
|
|
133
|
-
accessToken: v.string(),
|
|
134
|
-
refreshToken: v.string(),
|
|
135
|
-
expiresAt: v.number(),
|
|
136
|
-
},
|
|
137
|
-
returns: v.null(),
|
|
138
|
-
handler: async (ctx, args) => {
|
|
139
|
-
const existing = await ctx.db
|
|
140
|
-
.query("providerTokens")
|
|
141
|
-
.withIndex("by_connectionId", (q) =>
|
|
142
|
-
q.eq("connectionId", args.connectionId),
|
|
143
|
-
)
|
|
144
|
-
.first();
|
|
145
|
-
|
|
146
|
-
if (existing) {
|
|
147
|
-
await ctx.db.patch(existing._id, {
|
|
148
|
-
accessToken: args.accessToken,
|
|
149
|
-
refreshToken: args.refreshToken,
|
|
150
|
-
expiresAt: args.expiresAt,
|
|
151
|
-
});
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
await ctx.db.insert("providerTokens", {
|
|
156
|
-
connectionId: args.connectionId,
|
|
157
|
-
accessToken: args.accessToken,
|
|
158
|
-
refreshToken: args.refreshToken,
|
|
159
|
-
expiresAt: args.expiresAt,
|
|
160
|
-
});
|
|
161
|
-
return null;
|
|
162
|
-
},
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Get stored tokens for a connection.
|
|
167
|
-
*/
|
|
168
|
-
export const getTokens = internalQuery({
|
|
169
|
-
args: { connectionId: v.id("connections") },
|
|
170
|
-
returns: v.union(
|
|
171
|
-
v.object({
|
|
172
|
-
_id: v.id("providerTokens"),
|
|
173
|
-
_creationTime: v.number(),
|
|
174
|
-
connectionId: v.id("connections"),
|
|
175
|
-
accessToken: v.string(),
|
|
176
|
-
refreshToken: v.optional(v.string()),
|
|
177
|
-
expiresAt: v.optional(v.number()),
|
|
178
|
-
}),
|
|
179
|
-
v.null(),
|
|
180
|
-
),
|
|
181
|
-
handler: async (ctx, args) => {
|
|
182
|
-
return await ctx.db
|
|
183
|
-
.query("providerTokens")
|
|
184
|
-
.withIndex("by_connectionId", (q) =>
|
|
185
|
-
q.eq("connectionId", args.connectionId),
|
|
186
|
-
)
|
|
187
|
-
.first();
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Delete stored tokens for a connection.
|
|
193
|
-
*/
|
|
194
|
-
export const deleteTokens = internalMutation({
|
|
195
|
-
args: { connectionId: v.id("connections") },
|
|
196
|
-
returns: v.null(),
|
|
197
|
-
handler: async (ctx, args) => {
|
|
198
|
-
const existing = await ctx.db
|
|
199
|
-
.query("providerTokens")
|
|
200
|
-
.withIndex("by_connectionId", (q) =>
|
|
201
|
-
q.eq("connectionId", args.connectionId),
|
|
202
|
-
)
|
|
203
|
-
.first();
|
|
204
|
-
|
|
205
|
-
if (existing) {
|
|
206
|
-
await ctx.db.delete(existing._id);
|
|
207
|
-
}
|
|
208
|
-
return null;
|
|
209
|
-
},
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// ─── Public Actions ──────────────────────────────────────────────────────────
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Generate a Garmin OAuth 2.0 authorization URL with PKCE.
|
|
216
|
-
*
|
|
217
|
-
* If `userId` is provided, the PKCE code verifier and state are stored in the
|
|
218
|
-
* component's `pendingOAuth` table so that `completeGarminOAuth` can look
|
|
219
|
-
* them up automatically when the callback fires. This is the recommended
|
|
220
|
-
* flow when using `registerRoutes`.
|
|
221
|
-
*
|
|
222
|
-
* If `userId` is omitted, the host app must store the returned `codeVerifier`
|
|
223
|
-
* itself and pass it to `connectGarmin` manually.
|
|
224
|
-
*/
|
|
225
|
-
export const getGarminAuthUrl = action({
|
|
226
|
-
args: {
|
|
227
|
-
clientId: v.string(),
|
|
228
|
-
redirectUri: v.optional(v.string()),
|
|
229
|
-
userId: v.optional(v.string()),
|
|
230
|
-
},
|
|
231
|
-
returns: v.object({
|
|
232
|
-
authUrl: v.string(),
|
|
233
|
-
state: v.string(),
|
|
234
|
-
codeVerifier: v.string(),
|
|
235
|
-
}),
|
|
236
|
-
handler: async (ctx, args) => {
|
|
237
|
-
const codeVerifier = generateCodeVerifier();
|
|
238
|
-
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
239
|
-
const state = generateState();
|
|
240
|
-
|
|
241
|
-
const authUrl = buildAuthUrl({
|
|
242
|
-
clientId: args.clientId,
|
|
243
|
-
codeChallenge,
|
|
244
|
-
redirectUri: args.redirectUri,
|
|
245
|
-
state,
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
if (args.userId) {
|
|
249
|
-
await ctx.runMutation(internalApi.garmin.storePendingOAuth, {
|
|
250
|
-
provider: "GARMIN",
|
|
251
|
-
state,
|
|
252
|
-
codeVerifier,
|
|
253
|
-
userId: args.userId,
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return { authUrl, state, codeVerifier };
|
|
258
|
-
},
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Exchange an authorization code for tokens + initial sync.
|
|
263
|
-
*
|
|
264
|
-
* Used in the manual flow where the host app stores the code verifier
|
|
265
|
-
* and handles the callback itself.
|
|
266
|
-
*/
|
|
267
|
-
export const connectGarmin = action({
|
|
268
|
-
args: {
|
|
269
|
-
userId: v.string(),
|
|
270
|
-
clientId: v.string(),
|
|
271
|
-
clientSecret: v.string(),
|
|
272
|
-
code: v.string(),
|
|
273
|
-
codeVerifier: v.string(),
|
|
274
|
-
redirectUri: v.optional(v.string()),
|
|
275
|
-
},
|
|
276
|
-
returns: v.object({
|
|
277
|
-
connectionId: v.string(),
|
|
278
|
-
synced: syncedValidator,
|
|
279
|
-
errors: v.array(
|
|
280
|
-
v.object({ type: v.string(), id: v.string(), error: v.string() }),
|
|
281
|
-
),
|
|
282
|
-
}),
|
|
283
|
-
handler: async (ctx, args) => {
|
|
284
|
-
const tokenResult = await exchangeCode({
|
|
285
|
-
clientId: args.clientId,
|
|
286
|
-
clientSecret: args.clientSecret,
|
|
287
|
-
code: args.code,
|
|
288
|
-
codeVerifier: args.codeVerifier,
|
|
289
|
-
redirectUri: args.redirectUri,
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
const connectionId = await ctx.runMutation(publicApi.public.connect, {
|
|
293
|
-
userId: args.userId,
|
|
294
|
-
provider: "GARMIN",
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
|
|
298
|
-
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
299
|
-
connectionId,
|
|
300
|
-
accessToken: tokenResult.access_token,
|
|
301
|
-
refreshToken: tokenResult.refresh_token,
|
|
302
|
-
expiresAt,
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
const client = new GarminClient({
|
|
306
|
-
accessToken: tokenResult.access_token,
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
// Best-effort: resolve Garmin user ID for webhook mapping
|
|
310
|
-
const garminUserId = await client.getUserId();
|
|
311
|
-
if (garminUserId) {
|
|
312
|
-
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
313
|
-
connectionId,
|
|
314
|
-
providerUserId: garminUserId,
|
|
315
|
-
});
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const now = Math.floor(Date.now() / 1000);
|
|
319
|
-
const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
|
|
320
|
-
const timeRange = {
|
|
321
|
-
uploadStartTimeInSeconds: thirtyDaysAgo,
|
|
322
|
-
uploadEndTimeInSeconds: now,
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
const result = await syncAllTypes(ctx, client, {
|
|
326
|
-
connectionId,
|
|
327
|
-
userId: args.userId,
|
|
328
|
-
timeRange,
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
332
|
-
connectionId,
|
|
333
|
-
lastDataUpdate: new Date().toISOString(),
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
return {
|
|
337
|
-
connectionId,
|
|
338
|
-
synced: result.synced,
|
|
339
|
-
errors: result.errors,
|
|
340
|
-
};
|
|
341
|
-
},
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Complete a Garmin OAuth 2.0 flow using stored pending state.
|
|
346
|
-
*
|
|
347
|
-
* Used by `registerRoutes` — the callback handler calls this with the
|
|
348
|
-
* `code` and `state` from the redirect. The action looks up the pending
|
|
349
|
-
* state (codeVerifier, userId) stored during `getGarminAuthUrl`,
|
|
350
|
-
* exchanges for tokens, creates the connection, syncs data, and
|
|
351
|
-
* cleans up the pending entry.
|
|
352
|
-
*/
|
|
353
|
-
export const completeGarminOAuth = action({
|
|
354
|
-
args: {
|
|
355
|
-
code: v.string(),
|
|
356
|
-
state: v.string(),
|
|
357
|
-
clientId: v.string(),
|
|
358
|
-
clientSecret: v.string(),
|
|
359
|
-
redirectUri: v.optional(v.string()),
|
|
360
|
-
},
|
|
361
|
-
returns: v.object({
|
|
362
|
-
connectionId: v.string(),
|
|
363
|
-
synced: syncedValidator,
|
|
364
|
-
errors: v.array(
|
|
365
|
-
v.object({ type: v.string(), id: v.string(), error: v.string() }),
|
|
366
|
-
),
|
|
367
|
-
}),
|
|
368
|
-
handler: async (ctx, args) => {
|
|
369
|
-
const pending = await ctx.runQuery(internalApi.garmin.getPendingOAuth, {
|
|
370
|
-
state: args.state,
|
|
371
|
-
});
|
|
372
|
-
if (!pending) {
|
|
373
|
-
throw new Error(
|
|
374
|
-
"No pending Garmin OAuth state found for this state parameter. " +
|
|
375
|
-
"The authorization may have expired or was already used.",
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const tokenResult = await exchangeCode({
|
|
380
|
-
clientId: args.clientId,
|
|
381
|
-
clientSecret: args.clientSecret,
|
|
382
|
-
code: args.code,
|
|
383
|
-
codeVerifier: pending.codeVerifier,
|
|
384
|
-
redirectUri: args.redirectUri,
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
await ctx.runMutation(internalApi.garmin.deletePendingOAuth, {
|
|
388
|
-
state: args.state,
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
const connectionId = await ctx.runMutation(publicApi.public.connect, {
|
|
392
|
-
userId: pending.userId,
|
|
393
|
-
provider: "GARMIN",
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
|
|
397
|
-
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
398
|
-
connectionId,
|
|
399
|
-
accessToken: tokenResult.access_token,
|
|
400
|
-
refreshToken: tokenResult.refresh_token,
|
|
401
|
-
expiresAt,
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
const client = new GarminClient({
|
|
405
|
-
accessToken: tokenResult.access_token,
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
// Best-effort: resolve Garmin user ID for webhook mapping
|
|
409
|
-
const garminUserId = await client.getUserId();
|
|
410
|
-
if (garminUserId) {
|
|
411
|
-
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
412
|
-
connectionId,
|
|
413
|
-
providerUserId: garminUserId,
|
|
414
|
-
});
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const now = Math.floor(Date.now() / 1000);
|
|
418
|
-
const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
|
|
419
|
-
const timeRange = {
|
|
420
|
-
uploadStartTimeInSeconds: thirtyDaysAgo,
|
|
421
|
-
uploadEndTimeInSeconds: now,
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
const result = await syncAllTypes(ctx, client, {
|
|
425
|
-
connectionId,
|
|
426
|
-
userId: pending.userId,
|
|
427
|
-
timeRange,
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
431
|
-
connectionId,
|
|
432
|
-
lastDataUpdate: new Date().toISOString(),
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
return {
|
|
436
|
-
connectionId,
|
|
437
|
-
synced: result.synced,
|
|
438
|
-
errors: result.errors,
|
|
439
|
-
};
|
|
440
|
-
},
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* Incremental Garmin sync for an already-connected user.
|
|
445
|
-
*
|
|
446
|
-
* Looks up the stored tokens, refreshes if expired, and syncs all data
|
|
447
|
-
* types for the specified time range (defaults to last 30 days).
|
|
448
|
-
*/
|
|
449
|
-
export const syncGarmin = action({
|
|
450
|
-
args: {
|
|
451
|
-
userId: v.string(),
|
|
452
|
-
clientId: v.string(),
|
|
453
|
-
clientSecret: v.string(),
|
|
454
|
-
startTimeInSeconds: v.optional(v.number()),
|
|
455
|
-
endTimeInSeconds: v.optional(v.number()),
|
|
456
|
-
},
|
|
457
|
-
returns: v.object({
|
|
458
|
-
synced: syncedValidator,
|
|
459
|
-
errors: v.array(
|
|
460
|
-
v.object({ type: v.string(), id: v.string(), error: v.string() }),
|
|
461
|
-
),
|
|
462
|
-
}),
|
|
463
|
-
handler: async (ctx, args) => {
|
|
464
|
-
const connection = await ctx.runQuery(
|
|
465
|
-
internalApi.private.getConnectionByProvider,
|
|
466
|
-
{ userId: args.userId, provider: "GARMIN" },
|
|
467
|
-
);
|
|
468
|
-
if (!connection) {
|
|
469
|
-
throw new Error(
|
|
470
|
-
`No Garmin connection found for user "${args.userId}". ` +
|
|
471
|
-
"Call connectGarmin first.",
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
if (!connection.active) {
|
|
475
|
-
throw new Error(
|
|
476
|
-
`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
const connectionId = connection._id;
|
|
481
|
-
|
|
482
|
-
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
483
|
-
connectionId,
|
|
484
|
-
});
|
|
485
|
-
if (!tokenDoc) {
|
|
486
|
-
throw new Error(
|
|
487
|
-
"No Garmin tokens found for this connection. " +
|
|
488
|
-
"The connection may have been created before token storage was available.",
|
|
489
|
-
);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
let accessToken = tokenDoc.accessToken;
|
|
493
|
-
|
|
494
|
-
// Refresh the token if it's expired or about to expire
|
|
495
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
496
|
-
if (
|
|
497
|
-
tokenDoc.expiresAt &&
|
|
498
|
-
tokenDoc.refreshToken &&
|
|
499
|
-
nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
|
|
500
|
-
) {
|
|
501
|
-
const refreshed = await refreshToken({
|
|
502
|
-
clientId: args.clientId,
|
|
503
|
-
clientSecret: args.clientSecret,
|
|
504
|
-
refreshToken: tokenDoc.refreshToken,
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
accessToken = refreshed.access_token;
|
|
508
|
-
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
509
|
-
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
510
|
-
connectionId,
|
|
511
|
-
accessToken: refreshed.access_token,
|
|
512
|
-
refreshToken: refreshed.refresh_token,
|
|
513
|
-
expiresAt: newExpiresAt,
|
|
514
|
-
});
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
const client = new GarminClient({ accessToken });
|
|
518
|
-
|
|
519
|
-
// Lazy backfill: resolve Garmin user ID if missing (for webhook mapping)
|
|
520
|
-
if (!connection.providerUserId) {
|
|
521
|
-
const garminUserId = await client.getUserId();
|
|
522
|
-
if (garminUserId) {
|
|
523
|
-
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
524
|
-
connectionId,
|
|
525
|
-
providerUserId: garminUserId,
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
const now = Math.floor(Date.now() / 1000);
|
|
531
|
-
const timeRange = {
|
|
532
|
-
uploadStartTimeInSeconds:
|
|
533
|
-
args.startTimeInSeconds ?? now - DEFAULT_SYNC_DAYS * 86400,
|
|
534
|
-
uploadEndTimeInSeconds: args.endTimeInSeconds ?? now,
|
|
535
|
-
};
|
|
536
|
-
|
|
537
|
-
const result = await syncAllTypes(ctx, client, {
|
|
538
|
-
connectionId,
|
|
539
|
-
userId: args.userId,
|
|
540
|
-
timeRange,
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
544
|
-
connectionId,
|
|
545
|
-
lastDataUpdate: new Date().toISOString(),
|
|
546
|
-
});
|
|
547
|
-
|
|
548
|
-
return result;
|
|
549
|
-
},
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
/**
|
|
553
|
-
* Disconnect a user from Garmin.
|
|
554
|
-
*
|
|
555
|
-
* Deregisters the user via the Garmin API (best-effort), deletes stored
|
|
556
|
-
* tokens, and sets the connection to inactive.
|
|
557
|
-
*/
|
|
558
|
-
export const disconnectGarmin = action({
|
|
559
|
-
args: {
|
|
560
|
-
userId: v.string(),
|
|
561
|
-
},
|
|
562
|
-
returns: v.null(),
|
|
563
|
-
handler: async (ctx, args) => {
|
|
564
|
-
const connection = await ctx.runQuery(
|
|
565
|
-
internalApi.private.getConnectionByProvider,
|
|
566
|
-
{ userId: args.userId, provider: "GARMIN" },
|
|
567
|
-
);
|
|
568
|
-
if (!connection) {
|
|
569
|
-
throw new Error(
|
|
570
|
-
`No Garmin connection found for user "${args.userId}".`,
|
|
571
|
-
);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
const connectionId = connection._id;
|
|
575
|
-
|
|
576
|
-
// Best-effort: deregister user at Garmin
|
|
577
|
-
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
578
|
-
connectionId,
|
|
579
|
-
});
|
|
580
|
-
if (tokenDoc) {
|
|
581
|
-
try {
|
|
582
|
-
const client = new GarminClient({ accessToken: tokenDoc.accessToken });
|
|
583
|
-
await client.deleteUserRegistration();
|
|
584
|
-
} catch {
|
|
585
|
-
// Deregistration is best-effort; proceed with local cleanup
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
await ctx.runMutation(internalApi.garmin.deleteTokens, { connectionId });
|
|
590
|
-
|
|
591
|
-
await ctx.runMutation(publicApi.public.disconnect, {
|
|
592
|
-
userId: args.userId,
|
|
593
|
-
provider: "GARMIN",
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
return null;
|
|
597
|
-
},
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
// ─── Training API ────────────────────────────────────────────────────────────
|
|
601
|
-
|
|
602
|
-
/**
|
|
603
|
-
* Push a planned workout from Soma's DB to Garmin Connect.
|
|
604
|
-
*
|
|
605
|
-
* Reads the planned workout document, transforms it to Garmin Training API V2
|
|
606
|
-
* format, creates the workout at Garmin, and optionally schedules it if a
|
|
607
|
-
* `planned_date` is set in the metadata.
|
|
608
|
-
*
|
|
609
|
-
* Returns the Garmin workout ID and schedule ID (if scheduled).
|
|
610
|
-
*/
|
|
611
|
-
export const pushPlannedWorkout = action({
|
|
612
|
-
args: {
|
|
613
|
-
userId: v.string(),
|
|
614
|
-
clientId: v.string(),
|
|
615
|
-
clientSecret: v.string(),
|
|
616
|
-
plannedWorkoutId: v.string(),
|
|
617
|
-
workoutProvider: v.optional(v.string()),
|
|
618
|
-
},
|
|
619
|
-
returns: v.object({
|
|
620
|
-
garminWorkoutId: v.number(),
|
|
621
|
-
garminScheduleId: v.union(v.number(), v.null()),
|
|
622
|
-
}),
|
|
623
|
-
handler: async (ctx, args) => {
|
|
624
|
-
const connection = await ctx.runQuery(
|
|
625
|
-
internalApi.private.getConnectionByProvider,
|
|
626
|
-
{ userId: args.userId, provider: "GARMIN" },
|
|
627
|
-
);
|
|
628
|
-
if (!connection) {
|
|
629
|
-
throw new Error(
|
|
630
|
-
`No Garmin connection found for user "${args.userId}". ` +
|
|
631
|
-
"Call connectGarmin first.",
|
|
632
|
-
);
|
|
633
|
-
}
|
|
634
|
-
if (!connection.active) {
|
|
635
|
-
throw new Error(
|
|
636
|
-
`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const connectionId = connection._id;
|
|
641
|
-
|
|
642
|
-
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
643
|
-
connectionId,
|
|
644
|
-
});
|
|
645
|
-
if (!tokenDoc) {
|
|
646
|
-
throw new Error(
|
|
647
|
-
"No Garmin tokens found for this connection. " +
|
|
648
|
-
"The connection may have been created before token storage was available.",
|
|
649
|
-
);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// Always force-refresh the token for Training API calls to rule out
|
|
653
|
-
// stale tokens (the initial sync swallows 401 errors silently).
|
|
654
|
-
let accessToken = tokenDoc.accessToken;
|
|
655
|
-
|
|
656
|
-
if (tokenDoc.refreshToken) {
|
|
657
|
-
try {
|
|
658
|
-
const refreshed = await refreshToken({
|
|
659
|
-
clientId: args.clientId,
|
|
660
|
-
clientSecret: args.clientSecret,
|
|
661
|
-
refreshToken: tokenDoc.refreshToken,
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
accessToken = refreshed.access_token;
|
|
665
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
666
|
-
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
667
|
-
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
668
|
-
connectionId,
|
|
669
|
-
accessToken: refreshed.access_token,
|
|
670
|
-
refreshToken: refreshed.refresh_token,
|
|
671
|
-
expiresAt: newExpiresAt,
|
|
672
|
-
});
|
|
673
|
-
} catch (refreshErr) {
|
|
674
|
-
throw new Error(
|
|
675
|
-
`Garmin token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}. ` +
|
|
676
|
-
"The user may need to reconnect their Garmin account.",
|
|
677
|
-
);
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
const plannedWorkout = await ctx.runQuery(
|
|
682
|
-
publicApi.public.getPlannedWorkout,
|
|
683
|
-
{ plannedWorkoutId: args.plannedWorkoutId as never },
|
|
684
|
-
);
|
|
685
|
-
if (!plannedWorkout) {
|
|
686
|
-
throw new Error(
|
|
687
|
-
`Planned workout "${args.plannedWorkoutId}" not found.`,
|
|
688
|
-
);
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
const providerName = args.workoutProvider ?? "Soma";
|
|
692
|
-
const garminWorkout = transformPlannedWorkoutToGarmin(
|
|
693
|
-
plannedWorkout,
|
|
694
|
-
providerName,
|
|
695
|
-
);
|
|
696
|
-
|
|
697
|
-
const client = new GarminClient({ accessToken });
|
|
698
|
-
const created = await client.createWorkout(garminWorkout);
|
|
699
|
-
|
|
700
|
-
if (!created.workoutId) {
|
|
701
|
-
throw new Error("Garmin API did not return a workoutId after creation.");
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
let garminScheduleId: number | null = null;
|
|
705
|
-
|
|
706
|
-
const plannedDate = plannedWorkout.metadata?.planned_date;
|
|
707
|
-
if (plannedDate) {
|
|
708
|
-
const schedule = await client.createSchedule(
|
|
709
|
-
created.workoutId,
|
|
710
|
-
plannedDate,
|
|
711
|
-
);
|
|
712
|
-
garminScheduleId = schedule.scheduleId ?? null;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// Store the Garmin workout/schedule IDs back on the planned workout
|
|
716
|
-
// so the host app can match completed activities to planned sessions.
|
|
717
|
-
await ctx.runMutation(publicApi.public.ingestPlannedWorkout, {
|
|
718
|
-
...plannedWorkout,
|
|
719
|
-
_id: undefined,
|
|
720
|
-
_creationTime: undefined,
|
|
721
|
-
metadata: {
|
|
722
|
-
...plannedWorkout.metadata,
|
|
723
|
-
provider_workout_id: String(created.workoutId),
|
|
724
|
-
provider_schedule_id:
|
|
725
|
-
garminScheduleId != null ? String(garminScheduleId) : undefined,
|
|
726
|
-
},
|
|
727
|
-
} as never);
|
|
728
|
-
|
|
729
|
-
return {
|
|
730
|
-
garminWorkoutId: created.workoutId,
|
|
731
|
-
garminScheduleId,
|
|
732
|
-
};
|
|
733
|
-
},
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
// ─── Webhook Handlers (Push Mode) ────────────────────────────────────────────
|
|
737
|
-
// Each handler receives full Garmin data objects from push-mode webhooks.
|
|
738
|
-
// Separate actions per data type because the Garmin developer portal
|
|
739
|
-
// configures separate URLs per type.
|
|
740
|
-
|
|
741
|
-
const webhookResultValidator = v.object({
|
|
742
|
-
processed: v.number(),
|
|
743
|
-
errors: v.array(
|
|
744
|
-
v.object({ type: v.string(), id: v.string(), error: v.string() }),
|
|
745
|
-
),
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
/**
|
|
749
|
-
* Handle a webhook for Garmin activities (push or ping mode).
|
|
750
|
-
*
|
|
751
|
-
* Push mode: receives full GarminActivity objects, transforms, and ingests.
|
|
752
|
-
* Ping mode: receives notifications, fetches data from the Garmin API, transforms, and ingests.
|
|
753
|
-
*/
|
|
754
|
-
export const handleGarminWebhookActivities = action({
|
|
755
|
-
args: { payload: v.any() },
|
|
756
|
-
returns: webhookResultValidator,
|
|
757
|
-
handler: async (ctx, args) => {
|
|
758
|
-
return await processWebhookDualMode(ctx, {
|
|
759
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
760
|
-
type: "activity",
|
|
761
|
-
transform: (item) => transformActivity(item as never),
|
|
762
|
-
ingest: publicApi.public.ingestActivity,
|
|
763
|
-
getId: (item) =>
|
|
764
|
-
(item as { summaryId?: string; activityId?: number }).summaryId ??
|
|
765
|
-
String((item as { activityId?: number }).activityId ?? "unknown"),
|
|
766
|
-
fetchData: (client, timeRange) => client.getActivities(timeRange),
|
|
767
|
-
});
|
|
768
|
-
},
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
/**
|
|
772
|
-
* Handle a webhook for Garmin daily summaries (push or ping mode).
|
|
773
|
-
*/
|
|
774
|
-
export const handleGarminWebhookDailies = action({
|
|
775
|
-
args: { payload: v.any() },
|
|
776
|
-
returns: webhookResultValidator,
|
|
777
|
-
handler: async (ctx, args) => {
|
|
778
|
-
return await processWebhookDualMode(ctx, {
|
|
779
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
780
|
-
type: "daily",
|
|
781
|
-
transform: (item) => transformDaily(item as never),
|
|
782
|
-
ingest: publicApi.public.ingestDaily,
|
|
783
|
-
getId: (item) =>
|
|
784
|
-
(item as { summaryId?: string; calendarDate?: string }).summaryId ??
|
|
785
|
-
(item as { calendarDate?: string }).calendarDate ??
|
|
786
|
-
"unknown",
|
|
787
|
-
fetchData: (client, timeRange) => client.getDailies(timeRange),
|
|
788
|
-
});
|
|
789
|
-
},
|
|
790
|
-
});
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* Handle a webhook for Garmin sleep summaries (push or ping mode).
|
|
794
|
-
*/
|
|
795
|
-
export const handleGarminWebhookSleeps = action({
|
|
796
|
-
args: { payload: v.any() },
|
|
797
|
-
returns: webhookResultValidator,
|
|
798
|
-
handler: async (ctx, args) => {
|
|
799
|
-
return await processWebhookDualMode(ctx, {
|
|
800
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
801
|
-
type: "sleep",
|
|
802
|
-
transform: (item) => transformSleep(item as never),
|
|
803
|
-
ingest: publicApi.public.ingestSleep,
|
|
804
|
-
getId: (item) =>
|
|
805
|
-
(item as { summaryId?: string; calendarDate?: string }).summaryId ??
|
|
806
|
-
(item as { calendarDate?: string }).calendarDate ??
|
|
807
|
-
"unknown",
|
|
808
|
-
fetchData: (client, timeRange) => client.getSleeps(timeRange),
|
|
809
|
-
});
|
|
810
|
-
},
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
/**
|
|
814
|
-
* Handle a webhook for Garmin body composition summaries (push or ping mode).
|
|
815
|
-
*/
|
|
816
|
-
export const handleGarminWebhookBody = action({
|
|
817
|
-
args: { payload: v.any() },
|
|
818
|
-
returns: webhookResultValidator,
|
|
819
|
-
handler: async (ctx, args) => {
|
|
820
|
-
return await processWebhookDualMode(ctx, {
|
|
821
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
822
|
-
type: "body",
|
|
823
|
-
transform: (item) => transformBody(item as never),
|
|
824
|
-
ingest: publicApi.public.ingestBody,
|
|
825
|
-
getId: (item) =>
|
|
826
|
-
(item as { summaryId?: string; measurementTimeInSeconds?: number })
|
|
827
|
-
.summaryId ??
|
|
828
|
-
String(
|
|
829
|
-
(item as { measurementTimeInSeconds?: number })
|
|
830
|
-
.measurementTimeInSeconds ?? "unknown",
|
|
831
|
-
),
|
|
832
|
-
fetchData: (client, timeRange) => client.getBodyCompositions(timeRange),
|
|
833
|
-
});
|
|
834
|
-
},
|
|
835
|
-
});
|
|
836
|
-
|
|
837
|
-
/**
|
|
838
|
-
* Handle a webhook for Garmin menstrual cycle tracking (push or ping mode).
|
|
839
|
-
*/
|
|
840
|
-
export const handleGarminWebhookMenstruation = action({
|
|
841
|
-
args: { payload: v.any() },
|
|
842
|
-
returns: webhookResultValidator,
|
|
843
|
-
handler: async (ctx, args) => {
|
|
844
|
-
return await processWebhookDualMode(ctx, {
|
|
845
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
846
|
-
type: "menstruation",
|
|
847
|
-
transform: (item) => transformMenstruation(item as never),
|
|
848
|
-
ingest: publicApi.public.ingestMenstruation,
|
|
849
|
-
getId: (item) =>
|
|
850
|
-
(item as { summaryId?: string; calendarDate?: string }).summaryId ??
|
|
851
|
-
(item as { calendarDate?: string }).calendarDate ??
|
|
852
|
-
"unknown",
|
|
853
|
-
fetchData: (client, timeRange) => client.getMenstrualCycleData(timeRange),
|
|
854
|
-
});
|
|
855
|
-
},
|
|
856
|
-
});
|
|
857
|
-
|
|
858
|
-
/**
|
|
859
|
-
* Handle a webhook for Garmin blood pressure summaries (push or ping mode).
|
|
860
|
-
*/
|
|
861
|
-
export const handleGarminWebhookBloodPressures = action({
|
|
862
|
-
args: { payload: v.any() },
|
|
863
|
-
returns: webhookResultValidator,
|
|
864
|
-
handler: async (ctx, args) => {
|
|
865
|
-
return await processWebhookDualMode(ctx, {
|
|
866
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
867
|
-
type: "bloodPressure",
|
|
868
|
-
transform: (item) => transformBloodPressure(item as never),
|
|
869
|
-
ingest: publicApi.public.ingestBody,
|
|
870
|
-
getId: (item) =>
|
|
871
|
-
(item as { summaryId?: string; measurementTimeInSeconds?: number })
|
|
872
|
-
.summaryId ??
|
|
873
|
-
String(
|
|
874
|
-
(item as { measurementTimeInSeconds?: number })
|
|
875
|
-
.measurementTimeInSeconds ?? "unknown",
|
|
876
|
-
),
|
|
877
|
-
fetchData: (client, timeRange) => client.getBloodPressures(timeRange),
|
|
878
|
-
});
|
|
879
|
-
},
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
/**
|
|
883
|
-
* Handle a webhook for Garmin skin temperature summaries (push or ping mode).
|
|
884
|
-
*/
|
|
885
|
-
export const handleGarminWebhookSkinTemp = action({
|
|
886
|
-
args: { payload: v.any() },
|
|
887
|
-
returns: webhookResultValidator,
|
|
888
|
-
handler: async (ctx, args) => {
|
|
889
|
-
return await processWebhookDualMode(ctx, {
|
|
890
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
891
|
-
type: "skinTemp",
|
|
892
|
-
transform: (item) => transformSkinTemp(item as never),
|
|
893
|
-
ingest: publicApi.public.ingestBody,
|
|
894
|
-
getId: (item) =>
|
|
895
|
-
(item as { summaryId?: string; calendarDate?: string }).summaryId ??
|
|
896
|
-
(item as { calendarDate?: string }).calendarDate ??
|
|
897
|
-
"unknown",
|
|
898
|
-
fetchData: (client, timeRange) => client.getSkinTemperature(timeRange),
|
|
899
|
-
});
|
|
900
|
-
},
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
/**
|
|
904
|
-
* Handle a webhook for Garmin user metrics (push or ping mode).
|
|
905
|
-
*/
|
|
906
|
-
export const handleGarminWebhookUserMetrics = action({
|
|
907
|
-
args: { payload: v.any() },
|
|
908
|
-
returns: webhookResultValidator,
|
|
909
|
-
handler: async (ctx, args) => {
|
|
910
|
-
return await processWebhookDualMode(ctx, {
|
|
911
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
912
|
-
type: "userMetrics",
|
|
913
|
-
transform: (item) => transformUserMetrics(item as never),
|
|
914
|
-
ingest: publicApi.public.ingestBody,
|
|
915
|
-
getId: (item) =>
|
|
916
|
-
(item as { summaryId?: string; calendarDate?: string }).summaryId ??
|
|
917
|
-
(item as { calendarDate?: string }).calendarDate ??
|
|
918
|
-
"unknown",
|
|
919
|
-
fetchData: (client, timeRange) => client.getUserMetrics(timeRange),
|
|
920
|
-
});
|
|
921
|
-
},
|
|
922
|
-
});
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* Handle a webhook for Garmin HRV summaries (push or ping mode).
|
|
926
|
-
* Enriches daily records with heart_rate_data.
|
|
927
|
-
*/
|
|
928
|
-
export const handleGarminWebhookHRV = action({
|
|
929
|
-
args: { payload: v.any() },
|
|
930
|
-
returns: webhookResultValidator,
|
|
931
|
-
handler: async (ctx, args) => {
|
|
932
|
-
return await processWebhookDualMode(ctx, {
|
|
933
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
934
|
-
type: "hrv",
|
|
935
|
-
transform: (item) => {
|
|
936
|
-
const record = item as { startTimeInSeconds?: number; durationInSeconds?: number };
|
|
937
|
-
const data = transformHRV(item as never);
|
|
938
|
-
if (!data.heart_rate_data) return null;
|
|
939
|
-
return {
|
|
940
|
-
metadata: {
|
|
941
|
-
start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
|
|
942
|
-
end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
|
|
943
|
-
upload_type: 1,
|
|
944
|
-
},
|
|
945
|
-
heart_rate_data: data.heart_rate_data,
|
|
946
|
-
};
|
|
947
|
-
},
|
|
948
|
-
ingest: publicApi.public.ingestDaily,
|
|
949
|
-
getId: (item) =>
|
|
950
|
-
(item as { summaryId?: string; calendarDate?: string }).summaryId ??
|
|
951
|
-
(item as { calendarDate?: string }).calendarDate ??
|
|
952
|
-
"unknown",
|
|
953
|
-
fetchData: (client, timeRange) => client.getHRV(timeRange),
|
|
954
|
-
});
|
|
955
|
-
},
|
|
956
|
-
});
|
|
957
|
-
|
|
958
|
-
/**
|
|
959
|
-
* Handle a webhook for Garmin stress detail summaries (push or ping mode).
|
|
960
|
-
* Enriches daily records with stress_data.
|
|
961
|
-
*/
|
|
962
|
-
export const handleGarminWebhookStressDetails = action({
|
|
963
|
-
args: { payload: v.any() },
|
|
964
|
-
returns: webhookResultValidator,
|
|
965
|
-
handler: async (ctx, args) => {
|
|
966
|
-
return await processWebhookDualMode(ctx, {
|
|
967
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
968
|
-
type: "stressDetails",
|
|
969
|
-
transform: (item) => {
|
|
970
|
-
const record = item as { startTimeInSeconds?: number; durationInSeconds?: number };
|
|
971
|
-
const data = transformStressDetails(item as never);
|
|
972
|
-
if (!data.stress_data) return null;
|
|
973
|
-
return {
|
|
974
|
-
metadata: {
|
|
975
|
-
start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
|
|
976
|
-
end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
|
|
977
|
-
upload_type: 1,
|
|
978
|
-
},
|
|
979
|
-
stress_data: data.stress_data,
|
|
980
|
-
};
|
|
981
|
-
},
|
|
982
|
-
ingest: publicApi.public.ingestDaily,
|
|
983
|
-
getId: (item) =>
|
|
984
|
-
(item as { summaryId?: string; calendarDate?: string }).summaryId ??
|
|
985
|
-
(item as { calendarDate?: string }).calendarDate ??
|
|
986
|
-
"unknown",
|
|
987
|
-
fetchData: (client, timeRange) => client.getStressDetails(timeRange),
|
|
988
|
-
});
|
|
989
|
-
},
|
|
990
|
-
});
|
|
991
|
-
|
|
992
|
-
/**
|
|
993
|
-
* Handle a webhook for Garmin pulse oximetry (SpO2) summaries (push or ping mode).
|
|
994
|
-
* Enriches daily records with oxygen_data.
|
|
995
|
-
*/
|
|
996
|
-
export const handleGarminWebhookPulseOx = action({
|
|
997
|
-
args: { payload: v.any() },
|
|
998
|
-
returns: webhookResultValidator,
|
|
999
|
-
handler: async (ctx, args) => {
|
|
1000
|
-
return await processWebhookDualMode(ctx, {
|
|
1001
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
1002
|
-
type: "pulseOx",
|
|
1003
|
-
transform: (item) => {
|
|
1004
|
-
const record = item as { startTimeInSeconds?: number; durationInSeconds?: number };
|
|
1005
|
-
const data = transformPulseOx(item as never);
|
|
1006
|
-
if (!data.oxygen_data) return null;
|
|
1007
|
-
return {
|
|
1008
|
-
metadata: {
|
|
1009
|
-
start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
|
|
1010
|
-
end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
|
|
1011
|
-
upload_type: 1,
|
|
1012
|
-
},
|
|
1013
|
-
oxygen_data: data.oxygen_data,
|
|
1014
|
-
};
|
|
1015
|
-
},
|
|
1016
|
-
ingest: publicApi.public.ingestDaily,
|
|
1017
|
-
getId: (item) =>
|
|
1018
|
-
(item as { summaryId?: string; calendarDate?: string }).summaryId ??
|
|
1019
|
-
(item as { calendarDate?: string }).calendarDate ??
|
|
1020
|
-
"unknown",
|
|
1021
|
-
fetchData: (client, timeRange) => client.getPulseOx(timeRange),
|
|
1022
|
-
});
|
|
1023
|
-
},
|
|
1024
|
-
});
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* Handle a webhook for Garmin respiration summaries (push or ping mode).
|
|
1028
|
-
* Enriches daily records with respiration_data.
|
|
1029
|
-
*/
|
|
1030
|
-
export const handleGarminWebhookRespiration = action({
|
|
1031
|
-
args: { payload: v.any() },
|
|
1032
|
-
returns: webhookResultValidator,
|
|
1033
|
-
handler: async (ctx, args) => {
|
|
1034
|
-
return await processWebhookDualMode(ctx, {
|
|
1035
|
-
items: args.payload as Array<{ userId: string; [k: string]: unknown }>,
|
|
1036
|
-
type: "respiration",
|
|
1037
|
-
transform: (item) => {
|
|
1038
|
-
const record = item as { startTimeInSeconds?: number; durationInSeconds?: number };
|
|
1039
|
-
const data = transformRespiration(item as never);
|
|
1040
|
-
if (!data.respiration_data) return null;
|
|
1041
|
-
return {
|
|
1042
|
-
metadata: {
|
|
1043
|
-
start_time: new Date((record.startTimeInSeconds ?? 0) * 1000).toISOString(),
|
|
1044
|
-
end_time: new Date(((record.startTimeInSeconds ?? 0) + (record.durationInSeconds ?? 86400)) * 1000).toISOString(),
|
|
1045
|
-
upload_type: 1,
|
|
1046
|
-
},
|
|
1047
|
-
respiration_data: data.respiration_data,
|
|
1048
|
-
};
|
|
1049
|
-
},
|
|
1050
|
-
ingest: publicApi.public.ingestDaily,
|
|
1051
|
-
getId: (item) =>
|
|
1052
|
-
(item as { summaryId?: string }).summaryId ?? "unknown",
|
|
1053
|
-
fetchData: (client, timeRange) => client.getRespiration(timeRange),
|
|
1054
|
-
});
|
|
1055
|
-
},
|
|
1056
|
-
});
|
|
1057
|
-
|
|
1058
|
-
// ─── Webhook Internal Helper ─────────────────────────────────────────────────
|
|
1059
|
-
|
|
1060
|
-
interface WebhookProcessConfig {
|
|
1061
|
-
items: Array<{ userId: string; [k: string]: unknown }>;
|
|
1062
|
-
type: string;
|
|
1063
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1064
|
-
transform: (item: unknown) => any;
|
|
1065
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1066
|
-
ingest: any;
|
|
1067
|
-
getId: (item: unknown) => string;
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
async function processWebhookPayload(
|
|
1071
|
-
ctx: ActionContext,
|
|
1072
|
-
config: WebhookProcessConfig,
|
|
1073
|
-
) {
|
|
1074
|
-
const { items, type, transform, ingest, getId } = config;
|
|
1075
|
-
|
|
1076
|
-
let processed = 0;
|
|
1077
|
-
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
1078
|
-
|
|
1079
|
-
if (!Array.isArray(items)) {
|
|
1080
|
-
errors.push({ type, id: "payload", error: "Expected an array payload" });
|
|
1081
|
-
return { processed, errors };
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
// Group items by Garmin userId
|
|
1085
|
-
const byUser = new Map<string, Array<unknown>>();
|
|
1086
|
-
for (const item of items) {
|
|
1087
|
-
const garminUserId = item.userId;
|
|
1088
|
-
if (!garminUserId) {
|
|
1089
|
-
errors.push({ type, id: getId(item), error: "Missing userId in payload item" });
|
|
1090
|
-
continue;
|
|
1091
|
-
}
|
|
1092
|
-
if (!byUser.has(garminUserId)) byUser.set(garminUserId, []);
|
|
1093
|
-
byUser.get(garminUserId)!.push(item);
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
// Process each Garmin user's items
|
|
1097
|
-
for (const [garminUserId, userItems] of byUser) {
|
|
1098
|
-
const connection = await ctx.runQuery(
|
|
1099
|
-
internalApi.private.getConnectionByProviderUserId,
|
|
1100
|
-
{ providerUserId: garminUserId, provider: "GARMIN" },
|
|
1101
|
-
);
|
|
1102
|
-
|
|
1103
|
-
if (!connection) {
|
|
1104
|
-
for (const item of userItems) {
|
|
1105
|
-
errors.push({
|
|
1106
|
-
type,
|
|
1107
|
-
id: getId(item),
|
|
1108
|
-
error: `No Soma connection found for Garmin userId "${garminUserId}"`,
|
|
1109
|
-
});
|
|
1110
|
-
}
|
|
1111
|
-
continue;
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
if (!connection.active) {
|
|
1115
|
-
for (const item of userItems) {
|
|
1116
|
-
errors.push({
|
|
1117
|
-
type,
|
|
1118
|
-
id: getId(item),
|
|
1119
|
-
error: `Garmin connection for userId "${garminUserId}" is inactive`,
|
|
1120
|
-
});
|
|
1121
|
-
}
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
const connectionId = connection._id;
|
|
1126
|
-
const userId = connection.userId;
|
|
1127
|
-
|
|
1128
|
-
for (const item of userItems) {
|
|
1129
|
-
try {
|
|
1130
|
-
const data = transform(item);
|
|
1131
|
-
if (data == null) continue; // Skip items with no transformable data
|
|
1132
|
-
await ctx.runMutation(ingest, {
|
|
1133
|
-
connectionId,
|
|
1134
|
-
userId,
|
|
1135
|
-
...data,
|
|
1136
|
-
} as never);
|
|
1137
|
-
processed++;
|
|
1138
|
-
} catch (err) {
|
|
1139
|
-
errors.push({
|
|
1140
|
-
type,
|
|
1141
|
-
id: getId(item),
|
|
1142
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1143
|
-
});
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
// Update last data timestamp
|
|
1148
|
-
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
1149
|
-
connectionId,
|
|
1150
|
-
lastDataUpdate: new Date().toISOString(),
|
|
1151
|
-
});
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
return { processed, errors };
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// ─── Ping Mode Support ───────────────────────────────────────────────────────
|
|
1158
|
-
// In ping mode, Garmin sends a lightweight notification instead of full data.
|
|
1159
|
-
// The handler fetches the actual data from the Garmin API using stored tokens.
|
|
1160
|
-
|
|
1161
|
-
/**
|
|
1162
|
-
* Detect whether a webhook payload is ping mode (notification only) vs push
|
|
1163
|
-
* mode (full data). Ping payloads have `uploadStartTimeInSeconds` and very
|
|
1164
|
-
* few keys; push payloads contain the full summary object.
|
|
1165
|
-
*/
|
|
1166
|
-
function isPingMode(
|
|
1167
|
-
items: Array<{ [k: string]: unknown }>,
|
|
1168
|
-
): boolean {
|
|
1169
|
-
if (items.length === 0) return false;
|
|
1170
|
-
const item = items[0];
|
|
1171
|
-
return (
|
|
1172
|
-
"uploadStartTimeInSeconds" in item &&
|
|
1173
|
-
"uploadEndTimeInSeconds" in item &&
|
|
1174
|
-
Object.keys(item).length <= 6
|
|
1175
|
-
);
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
interface WebhookDualModeConfig extends WebhookProcessConfig {
|
|
1179
|
-
/** Fetch full records from the Garmin API (used in ping mode only). */
|
|
1180
|
-
fetchData: (
|
|
1181
|
-
client: GarminClient,
|
|
1182
|
-
timeRange: { uploadStartTimeInSeconds: number; uploadEndTimeInSeconds: number },
|
|
1183
|
-
) => Promise<unknown[]>;
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
/**
|
|
1187
|
-
* Process a webhook payload in either push or ping mode.
|
|
1188
|
-
*
|
|
1189
|
-
* - Push mode: items contain full data → transform and ingest directly.
|
|
1190
|
-
* - Ping mode: items are notifications → fetch data from the API, then
|
|
1191
|
-
* transform and ingest.
|
|
1192
|
-
*/
|
|
1193
|
-
async function processWebhookDualMode(
|
|
1194
|
-
ctx: ActionContext,
|
|
1195
|
-
config: WebhookDualModeConfig,
|
|
1196
|
-
) {
|
|
1197
|
-
const mode = isPingMode(config.items) ? "ping" : "push";
|
|
1198
|
-
console.log(
|
|
1199
|
-
`[garmin:webhook:${config.type}] mode=${mode} items=${config.items.length} payload:`,
|
|
1200
|
-
JSON.stringify(config.items, null, 2),
|
|
1201
|
-
);
|
|
1202
|
-
if (mode === "ping") {
|
|
1203
|
-
return await processWebhookPingPayload(ctx, config);
|
|
1204
|
-
}
|
|
1205
|
-
return await processWebhookPayload(ctx, config);
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
async function processWebhookPingPayload(
|
|
1209
|
-
ctx: ActionContext,
|
|
1210
|
-
config: WebhookDualModeConfig,
|
|
1211
|
-
) {
|
|
1212
|
-
const { items, type, fetchData, transform, ingest, getId } = config;
|
|
1213
|
-
|
|
1214
|
-
let processed = 0;
|
|
1215
|
-
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
1216
|
-
|
|
1217
|
-
if (!Array.isArray(items)) {
|
|
1218
|
-
errors.push({ type, id: "payload", error: "Expected an array payload" });
|
|
1219
|
-
return { processed, errors };
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
// Group by Garmin userId and merge time ranges
|
|
1223
|
-
const byUser = new Map<
|
|
1224
|
-
string,
|
|
1225
|
-
{ userAccessToken?: string; minStart: number; maxEnd: number }
|
|
1226
|
-
>();
|
|
1227
|
-
for (const item of items) {
|
|
1228
|
-
const garminUserId = (item as { userId?: string }).userId;
|
|
1229
|
-
if (!garminUserId) {
|
|
1230
|
-
errors.push({ type, id: "unknown", error: "Missing userId in ping notification" });
|
|
1231
|
-
continue;
|
|
1232
|
-
}
|
|
1233
|
-
const existing = byUser.get(garminUserId);
|
|
1234
|
-
const start = (item as { uploadStartTimeInSeconds?: number }).uploadStartTimeInSeconds ?? 0;
|
|
1235
|
-
const end = (item as { uploadEndTimeInSeconds?: number }).uploadEndTimeInSeconds ?? 0;
|
|
1236
|
-
const token = (item as { userAccessToken?: string }).userAccessToken;
|
|
1237
|
-
if (existing) {
|
|
1238
|
-
existing.minStart = Math.min(existing.minStart, start);
|
|
1239
|
-
existing.maxEnd = Math.max(existing.maxEnd, end);
|
|
1240
|
-
if (token && !existing.userAccessToken) existing.userAccessToken = token;
|
|
1241
|
-
} else {
|
|
1242
|
-
byUser.set(garminUserId, { userAccessToken: token, minStart: start, maxEnd: end });
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
for (const [garminUserId, notification] of byUser) {
|
|
1247
|
-
const connection = await ctx.runQuery(
|
|
1248
|
-
internalApi.private.getConnectionByProviderUserId,
|
|
1249
|
-
{ providerUserId: garminUserId, provider: "GARMIN" },
|
|
1250
|
-
);
|
|
1251
|
-
|
|
1252
|
-
if (!connection) {
|
|
1253
|
-
errors.push({
|
|
1254
|
-
type,
|
|
1255
|
-
id: "ping",
|
|
1256
|
-
error: `No Soma connection found for Garmin userId "${garminUserId}"`,
|
|
1257
|
-
});
|
|
1258
|
-
continue;
|
|
1259
|
-
}
|
|
1260
|
-
if (!connection.active) {
|
|
1261
|
-
errors.push({
|
|
1262
|
-
type,
|
|
1263
|
-
id: "ping",
|
|
1264
|
-
error: `Garmin connection for userId "${garminUserId}" is inactive`,
|
|
1265
|
-
});
|
|
1266
|
-
continue;
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
const connectionId = connection._id;
|
|
1270
|
-
const userId = connection.userId;
|
|
1271
|
-
|
|
1272
|
-
// Resolve a valid access token: prefer stored (with refresh), fall back to notification
|
|
1273
|
-
let accessToken: string | null = null;
|
|
1274
|
-
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
1275
|
-
connectionId,
|
|
1276
|
-
});
|
|
1277
|
-
if (tokenDoc) {
|
|
1278
|
-
accessToken = tokenDoc.accessToken;
|
|
1279
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
1280
|
-
if (
|
|
1281
|
-
tokenDoc.expiresAt &&
|
|
1282
|
-
tokenDoc.refreshToken &&
|
|
1283
|
-
nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
|
|
1284
|
-
) {
|
|
1285
|
-
const clientId = process.env.GARMIN_CLIENT_ID;
|
|
1286
|
-
const clientSecret = process.env.GARMIN_CLIENT_SECRET;
|
|
1287
|
-
if (clientId && clientSecret) {
|
|
1288
|
-
try {
|
|
1289
|
-
const refreshed = await refreshToken({
|
|
1290
|
-
clientId,
|
|
1291
|
-
clientSecret,
|
|
1292
|
-
refreshToken: tokenDoc.refreshToken,
|
|
1293
|
-
});
|
|
1294
|
-
accessToken = refreshed.access_token;
|
|
1295
|
-
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
1296
|
-
connectionId,
|
|
1297
|
-
accessToken: refreshed.access_token,
|
|
1298
|
-
refreshToken: refreshed.refresh_token,
|
|
1299
|
-
expiresAt: nowSeconds + refreshed.expires_in,
|
|
1300
|
-
});
|
|
1301
|
-
} catch {
|
|
1302
|
-
// Refresh failed — fall through to notification token
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
if (!accessToken) {
|
|
1308
|
-
accessToken = notification.userAccessToken ?? null;
|
|
1309
|
-
}
|
|
1310
|
-
if (!accessToken) {
|
|
1311
|
-
errors.push({
|
|
1312
|
-
type,
|
|
1313
|
-
id: "ping",
|
|
1314
|
-
error: `No access token available for Garmin userId "${garminUserId}"`,
|
|
1315
|
-
});
|
|
1316
|
-
continue;
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
const client = new GarminClient({ accessToken });
|
|
1320
|
-
|
|
1321
|
-
// Use the merged time range from all notifications for this user
|
|
1322
|
-
let { minStart, maxEnd } = notification;
|
|
1323
|
-
if (minStart === 0 && maxEnd === 0) {
|
|
1324
|
-
const now = Math.floor(Date.now() / 1000);
|
|
1325
|
-
minStart = now - 86400;
|
|
1326
|
-
maxEnd = now;
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
try {
|
|
1330
|
-
const records = await fetchData(client, {
|
|
1331
|
-
uploadStartTimeInSeconds: minStart,
|
|
1332
|
-
uploadEndTimeInSeconds: maxEnd,
|
|
1333
|
-
});
|
|
1334
|
-
|
|
1335
|
-
for (const record of records) {
|
|
1336
|
-
try {
|
|
1337
|
-
const data = transform(record);
|
|
1338
|
-
if (data == null) continue;
|
|
1339
|
-
await ctx.runMutation(ingest, {
|
|
1340
|
-
connectionId,
|
|
1341
|
-
userId,
|
|
1342
|
-
...data,
|
|
1343
|
-
} as never);
|
|
1344
|
-
processed++;
|
|
1345
|
-
} catch (err) {
|
|
1346
|
-
errors.push({
|
|
1347
|
-
type,
|
|
1348
|
-
id: getId(record),
|
|
1349
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1350
|
-
});
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1353
|
-
} catch (err) {
|
|
1354
|
-
errors.push({
|
|
1355
|
-
type,
|
|
1356
|
-
id: "fetch",
|
|
1357
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1358
|
-
});
|
|
1359
|
-
}
|
|
1360
|
-
|
|
1361
|
-
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
1362
|
-
connectionId,
|
|
1363
|
-
lastDataUpdate: new Date().toISOString(),
|
|
1364
|
-
});
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
return { processed, errors };
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
|
1371
|
-
|
|
1372
|
-
interface SyncAllConfig {
|
|
1373
|
-
connectionId: string;
|
|
1374
|
-
userId: string;
|
|
1375
|
-
timeRange: { uploadStartTimeInSeconds: number; uploadEndTimeInSeconds: number };
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1379
|
-
type ActionContext = { runMutation: (ref: any, args: any) => Promise<any>; runQuery: (ref: any, args: any) => Promise<any> };
|
|
1380
|
-
|
|
1381
|
-
async function syncAllTypes(
|
|
1382
|
-
ctx: ActionContext,
|
|
1383
|
-
client: GarminClient,
|
|
1384
|
-
config: SyncAllConfig,
|
|
1385
|
-
) {
|
|
1386
|
-
const { connectionId, userId, timeRange } = config;
|
|
1387
|
-
|
|
1388
|
-
const synced = {
|
|
1389
|
-
activities: 0, dailies: 0, sleep: 0, body: 0, menstruation: 0,
|
|
1390
|
-
bloodPressures: 0, skinTemp: 0, userMetrics: 0,
|
|
1391
|
-
hrv: 0, stressDetails: 0, pulseOx: 0, respiration: 0,
|
|
1392
|
-
};
|
|
1393
|
-
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
1394
|
-
|
|
1395
|
-
// ── Activities ──────────────────────────────────────────────────────────
|
|
1396
|
-
try {
|
|
1397
|
-
const activities = await client.getActivities(timeRange);
|
|
1398
|
-
for (const activity of activities) {
|
|
1399
|
-
try {
|
|
1400
|
-
const data = transformActivity(activity);
|
|
1401
|
-
await ctx.runMutation(publicApi.public.ingestActivity, {
|
|
1402
|
-
connectionId,
|
|
1403
|
-
userId,
|
|
1404
|
-
...data,
|
|
1405
|
-
} as never);
|
|
1406
|
-
synced.activities++;
|
|
1407
|
-
} catch (err) {
|
|
1408
|
-
errors.push({
|
|
1409
|
-
type: "activity",
|
|
1410
|
-
id: activity.summaryId ?? String(activity.activityId),
|
|
1411
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1412
|
-
});
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
} catch (err) {
|
|
1416
|
-
errors.push({
|
|
1417
|
-
type: "activity",
|
|
1418
|
-
id: "fetch",
|
|
1419
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1420
|
-
});
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
// ── Dailies ─────────────────────────────────────────────────────────────
|
|
1424
|
-
try {
|
|
1425
|
-
const dailies = await client.getDailies(timeRange);
|
|
1426
|
-
for (const daily of dailies) {
|
|
1427
|
-
try {
|
|
1428
|
-
const data = transformDaily(daily);
|
|
1429
|
-
await ctx.runMutation(publicApi.public.ingestDaily, {
|
|
1430
|
-
connectionId,
|
|
1431
|
-
userId,
|
|
1432
|
-
...data,
|
|
1433
|
-
} as never);
|
|
1434
|
-
synced.dailies++;
|
|
1435
|
-
} catch (err) {
|
|
1436
|
-
errors.push({
|
|
1437
|
-
type: "daily",
|
|
1438
|
-
id: daily.summaryId ?? daily.calendarDate ?? "unknown",
|
|
1439
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1440
|
-
});
|
|
1441
|
-
}
|
|
1442
|
-
}
|
|
1443
|
-
} catch (err) {
|
|
1444
|
-
errors.push({
|
|
1445
|
-
type: "daily",
|
|
1446
|
-
id: "fetch",
|
|
1447
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1448
|
-
});
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
// ── Sleep ───────────────────────────────────────────────────────────────
|
|
1452
|
-
try {
|
|
1453
|
-
const sleeps = await client.getSleeps(timeRange);
|
|
1454
|
-
for (const sleep of sleeps) {
|
|
1455
|
-
try {
|
|
1456
|
-
const data = transformSleep(sleep);
|
|
1457
|
-
await ctx.runMutation(publicApi.public.ingestSleep, {
|
|
1458
|
-
connectionId,
|
|
1459
|
-
userId,
|
|
1460
|
-
...data,
|
|
1461
|
-
} as never);
|
|
1462
|
-
synced.sleep++;
|
|
1463
|
-
} catch (err) {
|
|
1464
|
-
errors.push({
|
|
1465
|
-
type: "sleep",
|
|
1466
|
-
id: sleep.summaryId ?? sleep.calendarDate ?? "unknown",
|
|
1467
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1468
|
-
});
|
|
1469
|
-
}
|
|
1470
|
-
}
|
|
1471
|
-
} catch (err) {
|
|
1472
|
-
errors.push({
|
|
1473
|
-
type: "sleep",
|
|
1474
|
-
id: "fetch",
|
|
1475
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1476
|
-
});
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
// ── Body ────────────────────────────────────────────────────────────────
|
|
1480
|
-
try {
|
|
1481
|
-
const bodyComps = await client.getBodyCompositions(timeRange);
|
|
1482
|
-
for (const body of bodyComps) {
|
|
1483
|
-
try {
|
|
1484
|
-
const data = transformBody(body);
|
|
1485
|
-
await ctx.runMutation(publicApi.public.ingestBody, {
|
|
1486
|
-
connectionId,
|
|
1487
|
-
userId,
|
|
1488
|
-
...data,
|
|
1489
|
-
} as never);
|
|
1490
|
-
synced.body++;
|
|
1491
|
-
} catch (err) {
|
|
1492
|
-
errors.push({
|
|
1493
|
-
type: "body",
|
|
1494
|
-
id: body.summaryId ?? String(body.measurementTimeInSeconds),
|
|
1495
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1496
|
-
});
|
|
1497
|
-
}
|
|
1498
|
-
}
|
|
1499
|
-
} catch (err) {
|
|
1500
|
-
errors.push({
|
|
1501
|
-
type: "body",
|
|
1502
|
-
id: "fetch",
|
|
1503
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1504
|
-
});
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
// ── Menstruation ────────────────────────────────────────────────────────
|
|
1508
|
-
try {
|
|
1509
|
-
const records = await client.getMenstrualCycleData(timeRange);
|
|
1510
|
-
for (const record of records) {
|
|
1511
|
-
try {
|
|
1512
|
-
const data = transformMenstruation(record);
|
|
1513
|
-
await ctx.runMutation(publicApi.public.ingestMenstruation, {
|
|
1514
|
-
connectionId,
|
|
1515
|
-
userId,
|
|
1516
|
-
...data,
|
|
1517
|
-
} as never);
|
|
1518
|
-
synced.menstruation++;
|
|
1519
|
-
} catch (err) {
|
|
1520
|
-
errors.push({
|
|
1521
|
-
type: "menstruation",
|
|
1522
|
-
id: record.summaryId ?? record.periodStartDate ?? "unknown",
|
|
1523
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1524
|
-
});
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
} catch (err) {
|
|
1528
|
-
errors.push({
|
|
1529
|
-
type: "menstruation",
|
|
1530
|
-
id: "fetch",
|
|
1531
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1532
|
-
});
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
// ── Blood Pressures (→ body) ───────────────────────────────────────────
|
|
1536
|
-
try {
|
|
1537
|
-
const bpRecords = await client.getBloodPressures(timeRange);
|
|
1538
|
-
for (const bp of bpRecords) {
|
|
1539
|
-
try {
|
|
1540
|
-
const data = transformBloodPressure(bp);
|
|
1541
|
-
await ctx.runMutation(publicApi.public.ingestBody, {
|
|
1542
|
-
connectionId, userId, ...data,
|
|
1543
|
-
} as never);
|
|
1544
|
-
synced.bloodPressures++;
|
|
1545
|
-
} catch (err) {
|
|
1546
|
-
errors.push({
|
|
1547
|
-
type: "bloodPressure",
|
|
1548
|
-
id: bp.summaryId ?? String(bp.measurementTimeInSeconds),
|
|
1549
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1550
|
-
});
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1553
|
-
} catch (err) {
|
|
1554
|
-
errors.push({ type: "bloodPressure", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
// ── Skin Temperature (→ body) ──────────────────────────────────────────
|
|
1558
|
-
try {
|
|
1559
|
-
const skinRecords = await client.getSkinTemperature(timeRange);
|
|
1560
|
-
for (const skin of skinRecords) {
|
|
1561
|
-
try {
|
|
1562
|
-
const data = transformSkinTemp(skin);
|
|
1563
|
-
await ctx.runMutation(publicApi.public.ingestBody, {
|
|
1564
|
-
connectionId, userId, ...data,
|
|
1565
|
-
} as never);
|
|
1566
|
-
synced.skinTemp++;
|
|
1567
|
-
} catch (err) {
|
|
1568
|
-
errors.push({
|
|
1569
|
-
type: "skinTemp",
|
|
1570
|
-
id: skin.summaryId ?? skin.calendarDate ?? "unknown",
|
|
1571
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1572
|
-
});
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
} catch (err) {
|
|
1576
|
-
errors.push({ type: "skinTemp", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
// ── User Metrics (→ body) ──────────────────────────────────────────────
|
|
1580
|
-
try {
|
|
1581
|
-
const metricsRecords = await client.getUserMetrics(timeRange);
|
|
1582
|
-
for (const metrics of metricsRecords) {
|
|
1583
|
-
try {
|
|
1584
|
-
const data = transformUserMetrics(metrics);
|
|
1585
|
-
await ctx.runMutation(publicApi.public.ingestBody, {
|
|
1586
|
-
connectionId, userId, ...data,
|
|
1587
|
-
} as never);
|
|
1588
|
-
synced.userMetrics++;
|
|
1589
|
-
} catch (err) {
|
|
1590
|
-
errors.push({
|
|
1591
|
-
type: "userMetrics",
|
|
1592
|
-
id: metrics.summaryId ?? metrics.calendarDate ?? "unknown",
|
|
1593
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1594
|
-
});
|
|
1595
|
-
}
|
|
1596
|
-
}
|
|
1597
|
-
} catch (err) {
|
|
1598
|
-
errors.push({ type: "userMetrics", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1599
|
-
}
|
|
1600
|
-
|
|
1601
|
-
// ── HRV (enriches daily) ──────────────────────────────────────────────
|
|
1602
|
-
try {
|
|
1603
|
-
const hrvRecords = await client.getHRV(timeRange);
|
|
1604
|
-
for (const hrv of hrvRecords) {
|
|
1605
|
-
try {
|
|
1606
|
-
const data = transformHRV(hrv);
|
|
1607
|
-
if (data.heart_rate_data) {
|
|
1608
|
-
await ctx.runMutation(publicApi.public.ingestDaily, {
|
|
1609
|
-
connectionId, userId,
|
|
1610
|
-
metadata: {
|
|
1611
|
-
start_time: new Date((hrv.startTimeInSeconds ?? 0) * 1000).toISOString(),
|
|
1612
|
-
end_time: new Date(((hrv.startTimeInSeconds ?? 0) + (hrv.durationInSeconds ?? 86400)) * 1000).toISOString(),
|
|
1613
|
-
upload_type: 1,
|
|
1614
|
-
},
|
|
1615
|
-
heart_rate_data: data.heart_rate_data,
|
|
1616
|
-
} as never);
|
|
1617
|
-
synced.hrv++;
|
|
1618
|
-
}
|
|
1619
|
-
} catch (err) {
|
|
1620
|
-
errors.push({
|
|
1621
|
-
type: "hrv",
|
|
1622
|
-
id: hrv.summaryId ?? hrv.calendarDate ?? "unknown",
|
|
1623
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1624
|
-
});
|
|
1625
|
-
}
|
|
1626
|
-
}
|
|
1627
|
-
} catch (err) {
|
|
1628
|
-
errors.push({ type: "hrv", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
// ── Stress Details (enriches daily) ────────────────────────────────────
|
|
1632
|
-
try {
|
|
1633
|
-
const stressRecords = await client.getStressDetails(timeRange);
|
|
1634
|
-
for (const stress of stressRecords) {
|
|
1635
|
-
try {
|
|
1636
|
-
const data = transformStressDetails(stress);
|
|
1637
|
-
if (data.stress_data) {
|
|
1638
|
-
await ctx.runMutation(publicApi.public.ingestDaily, {
|
|
1639
|
-
connectionId, userId,
|
|
1640
|
-
metadata: {
|
|
1641
|
-
start_time: new Date((stress.startTimeInSeconds ?? 0) * 1000).toISOString(),
|
|
1642
|
-
end_time: new Date(((stress.startTimeInSeconds ?? 0) + (stress.durationInSeconds ?? 86400)) * 1000).toISOString(),
|
|
1643
|
-
upload_type: 1,
|
|
1644
|
-
},
|
|
1645
|
-
stress_data: data.stress_data,
|
|
1646
|
-
} as never);
|
|
1647
|
-
synced.stressDetails++;
|
|
1648
|
-
}
|
|
1649
|
-
} catch (err) {
|
|
1650
|
-
errors.push({
|
|
1651
|
-
type: "stressDetails",
|
|
1652
|
-
id: stress.summaryId ?? stress.calendarDate ?? "unknown",
|
|
1653
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1654
|
-
});
|
|
1655
|
-
}
|
|
1656
|
-
}
|
|
1657
|
-
} catch (err) {
|
|
1658
|
-
errors.push({ type: "stressDetails", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
// ── Pulse Ox (enriches daily) ──────────────────────────────────────────
|
|
1662
|
-
try {
|
|
1663
|
-
const pulseOxRecords = await client.getPulseOx(timeRange);
|
|
1664
|
-
for (const po of pulseOxRecords) {
|
|
1665
|
-
try {
|
|
1666
|
-
const data = transformPulseOx(po);
|
|
1667
|
-
if (data.oxygen_data) {
|
|
1668
|
-
await ctx.runMutation(publicApi.public.ingestDaily, {
|
|
1669
|
-
connectionId, userId,
|
|
1670
|
-
metadata: {
|
|
1671
|
-
start_time: new Date((po.startTimeInSeconds ?? 0) * 1000).toISOString(),
|
|
1672
|
-
end_time: new Date(((po.startTimeInSeconds ?? 0) + (po.durationInSeconds ?? 86400)) * 1000).toISOString(),
|
|
1673
|
-
upload_type: 1,
|
|
1674
|
-
},
|
|
1675
|
-
oxygen_data: data.oxygen_data,
|
|
1676
|
-
} as never);
|
|
1677
|
-
synced.pulseOx++;
|
|
1678
|
-
}
|
|
1679
|
-
} catch (err) {
|
|
1680
|
-
errors.push({
|
|
1681
|
-
type: "pulseOx",
|
|
1682
|
-
id: po.summaryId ?? po.calendarDate ?? "unknown",
|
|
1683
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1684
|
-
});
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
} catch (err) {
|
|
1688
|
-
errors.push({ type: "pulseOx", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
// ── Respiration (enriches daily) ───────────────────────────────────────
|
|
1692
|
-
try {
|
|
1693
|
-
const respRecords = await client.getRespiration(timeRange);
|
|
1694
|
-
for (const resp of respRecords) {
|
|
1695
|
-
try {
|
|
1696
|
-
const data = transformRespiration(resp);
|
|
1697
|
-
if (data.respiration_data) {
|
|
1698
|
-
await ctx.runMutation(publicApi.public.ingestDaily, {
|
|
1699
|
-
connectionId, userId,
|
|
1700
|
-
metadata: {
|
|
1701
|
-
start_time: new Date((resp.startTimeInSeconds ?? 0) * 1000).toISOString(),
|
|
1702
|
-
end_time: new Date(((resp.startTimeInSeconds ?? 0) + (resp.durationInSeconds ?? 86400)) * 1000).toISOString(),
|
|
1703
|
-
upload_type: 1,
|
|
1704
|
-
},
|
|
1705
|
-
respiration_data: data.respiration_data,
|
|
1706
|
-
} as never);
|
|
1707
|
-
synced.respiration++;
|
|
1708
|
-
}
|
|
1709
|
-
} catch (err) {
|
|
1710
|
-
errors.push({
|
|
1711
|
-
type: "respiration",
|
|
1712
|
-
id: resp.summaryId ?? "unknown",
|
|
1713
|
-
error: err instanceof Error ? err.message : String(err),
|
|
1714
|
-
});
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
} catch (err) {
|
|
1718
|
-
errors.push({ type: "respiration", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
1719
|
-
}
|
|
1720
|
-
|
|
1721
|
-
return { synced, errors };
|
|
1722
|
-
}
|