@nativesquare/soma 0.10.0 → 0.10.2
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 +85 -163
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +109 -130
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +92 -35
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin/private.d.ts +9 -0
- package/dist/component/garmin/private.d.ts.map +1 -1
- package/dist/component/garmin/private.js +49 -0
- package/dist/component/garmin/private.js.map +1 -1
- package/dist/component/garmin/public.d.ts +237 -62
- package/dist/component/garmin/public.d.ts.map +1 -1
- package/dist/component/garmin/public.js +683 -253
- package/dist/component/garmin/public.js.map +1 -1
- package/dist/component/garmin/utils.d.ts +8 -0
- package/dist/component/garmin/utils.d.ts.map +1 -1
- package/dist/component/garmin/utils.js +9 -0
- package/dist/component/garmin/utils.js.map +1 -1
- package/dist/component/strava/public.d.ts +12 -52
- package/dist/component/strava/public.d.ts.map +1 -1
- package/dist/component/strava/public.js +16 -92
- package/dist/component/strava/public.js.map +1 -1
- package/dist/component/strava/transform/activity.js +15 -9
- package/dist/component/strava/transform/activity.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +210 -158
- package/src/component/_generated/component.ts +165 -31
- package/src/component/garmin/private.ts +84 -0
- package/src/component/garmin/public.ts +804 -347
- package/src/component/garmin/utils.ts +17 -0
- package/src/component/strava/public.ts +17 -123
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
createWellnessClient,
|
|
19
19
|
createTrainingClient,
|
|
20
20
|
} from "./client.js";
|
|
21
|
-
import { timeRangeQuery } from "./utils.js";
|
|
21
|
+
import { buildTimeRangeQuery, timeRangeQuery } from "./utils.js";
|
|
22
22
|
import {
|
|
23
23
|
createWorkoutV2 as sdkCreateWorkoutV2,
|
|
24
24
|
createWorkoutSchedule as sdkCreateWorkoutSchedule,
|
|
@@ -60,24 +60,20 @@ const DEFAULT_SYNC_DAYS = 30;
|
|
|
60
60
|
// Refresh buffer: refresh tokens 10 minutes before expiry
|
|
61
61
|
const REFRESH_BUFFER_SECONDS = 600;
|
|
62
62
|
|
|
63
|
-
// ───
|
|
63
|
+
// ─── OAuth ──────────────────────────────────────────────────────────────────
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* Generate a Garmin OAuth 2.0 authorization URL with PKCE.
|
|
67
67
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* flow when using `registerRoutes`.
|
|
72
|
-
*
|
|
73
|
-
* If `userId` is omitted, the host app must store the returned `codeVerifier`
|
|
74
|
-
* itself and pass it to `connectGarmin` manually.
|
|
68
|
+
* The PKCE code verifier and state are stored in the component's
|
|
69
|
+
* `pendingOAuth` table so that `completeGarminOAuth` can look them up
|
|
70
|
+
* automatically when the callback fires via `registerRoutes`.
|
|
75
71
|
*/
|
|
76
72
|
export const getGarminAuthUrl = action({
|
|
77
73
|
args: {
|
|
78
74
|
clientId: v.string(),
|
|
79
75
|
redirectUri: v.optional(v.string()),
|
|
80
|
-
userId: v.
|
|
76
|
+
userId: v.string(),
|
|
81
77
|
},
|
|
82
78
|
handler: async (ctx, args) => {
|
|
83
79
|
const codeVerifier = generateCodeVerifier();
|
|
@@ -91,109 +87,27 @@ export const getGarminAuthUrl = action({
|
|
|
91
87
|
state,
|
|
92
88
|
});
|
|
93
89
|
|
|
94
|
-
|
|
95
|
-
await ctx.runMutation(internal.garmin.private.storePendingOAuth, {
|
|
96
|
-
provider: "GARMIN",
|
|
97
|
-
state,
|
|
98
|
-
codeVerifier,
|
|
99
|
-
userId: args.userId,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return { authUrl, state, codeVerifier };
|
|
104
|
-
},
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Exchange an authorization code for tokens + initial sync.
|
|
109
|
-
*
|
|
110
|
-
* Used in the manual flow where the host app stores the code verifier
|
|
111
|
-
* and handles the callback itself.
|
|
112
|
-
*/
|
|
113
|
-
export const connectGarmin = action({
|
|
114
|
-
args: {
|
|
115
|
-
userId: v.string(),
|
|
116
|
-
clientId: v.string(),
|
|
117
|
-
clientSecret: v.string(),
|
|
118
|
-
code: v.string(),
|
|
119
|
-
codeVerifier: v.string(),
|
|
120
|
-
redirectUri: v.optional(v.string()),
|
|
121
|
-
},
|
|
122
|
-
handler: async (ctx, args): Promise<{
|
|
123
|
-
connectionId: Id<"connections">;
|
|
124
|
-
userId: string;
|
|
125
|
-
synced: Record<string, number>;
|
|
126
|
-
errors: Array<{ type: string; id: string; error: string }>;
|
|
127
|
-
}> => {
|
|
128
|
-
const tokenResult = await exchangeCode({
|
|
129
|
-
clientId: args.clientId,
|
|
130
|
-
clientSecret: args.clientSecret,
|
|
131
|
-
code: args.code,
|
|
132
|
-
codeVerifier: args.codeVerifier,
|
|
133
|
-
redirectUri: args.redirectUri,
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const connectionId: Id<"connections"> = await ctx.runMutation(api.public.connect, {
|
|
137
|
-
userId: args.userId,
|
|
90
|
+
await ctx.runMutation(internal.garmin.private.storePendingOAuth, {
|
|
138
91
|
provider: "GARMIN",
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
|
|
142
|
-
const _stored: null = await ctx.runMutation(internal.garmin.private.storeTokens, {
|
|
143
|
-
connectionId,
|
|
144
|
-
accessToken: tokenResult.access_token,
|
|
145
|
-
refreshToken: tokenResult.refresh_token,
|
|
146
|
-
expiresAt,
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
// Best-effort: resolve Garmin user ID for webhook mapping
|
|
150
|
-
const wellnessClient = createWellnessClient(tokenResult.access_token);
|
|
151
|
-
const { data: userIdData } = await sdkUserId({ client: wellnessClient });
|
|
152
|
-
const garminUserId = userIdData?.userId ?? null;
|
|
153
|
-
if (garminUserId) {
|
|
154
|
-
const _updated: null = await ctx.runMutation(api.public.updateConnection, {
|
|
155
|
-
connectionId,
|
|
156
|
-
providerUserId: garminUserId,
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const now = Math.floor(Date.now() / 1000);
|
|
161
|
-
const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
|
|
162
|
-
const timeRange = {
|
|
163
|
-
uploadStartTimeInSeconds: thirtyDaysAgo,
|
|
164
|
-
uploadEndTimeInSeconds: now,
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const result = await ctx.runAction(api.garmin.public.syncAllTypes, {
|
|
168
|
-
accessToken: tokenResult.access_token,
|
|
169
|
-
connectionId,
|
|
92
|
+
state,
|
|
93
|
+
codeVerifier,
|
|
170
94
|
userId: args.userId,
|
|
171
|
-
uploadStartTimeInSeconds: timeRange.uploadStartTimeInSeconds,
|
|
172
|
-
uploadEndTimeInSeconds: timeRange.uploadEndTimeInSeconds,
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
const _updated: null = await ctx.runMutation(api.public.updateConnection, {
|
|
176
|
-
connectionId,
|
|
177
|
-
lastDataUpdate: new Date().toISOString(),
|
|
178
95
|
});
|
|
179
96
|
|
|
180
|
-
return {
|
|
181
|
-
connectionId,
|
|
182
|
-
userId: args.userId,
|
|
183
|
-
synced: result.synced,
|
|
184
|
-
errors: result.errors,
|
|
185
|
-
};
|
|
97
|
+
return { authUrl, state, codeVerifier };
|
|
186
98
|
},
|
|
187
99
|
});
|
|
188
100
|
|
|
189
101
|
/**
|
|
190
102
|
* Complete a Garmin OAuth 2.0 flow using stored pending state.
|
|
191
103
|
*
|
|
192
|
-
*
|
|
193
|
-
* `code` and `state` from the redirect. The action looks
|
|
194
|
-
* state (codeVerifier, userId) stored during
|
|
195
|
-
* exchanges for tokens, creates the connection,
|
|
196
|
-
* cleans up the pending entry.
|
|
104
|
+
* Called internally by `registerRoutes` — the callback handler calls
|
|
105
|
+
* this with the `code` and `state` from the redirect. The action looks
|
|
106
|
+
* up the pending state (codeVerifier, userId) stored during
|
|
107
|
+
* `getGarminAuthUrl`, exchanges for tokens, creates the connection,
|
|
108
|
+
* stores tokens, and cleans up the pending entry.
|
|
109
|
+
*
|
|
110
|
+
* The host app is responsible for calling `syncGarmin` afterwards.
|
|
197
111
|
*/
|
|
198
112
|
export const completeGarminOAuth = action({
|
|
199
113
|
args: {
|
|
@@ -206,8 +120,6 @@ export const completeGarminOAuth = action({
|
|
|
206
120
|
handler: async (ctx, args): Promise<{
|
|
207
121
|
connectionId: Id<"connections">;
|
|
208
122
|
userId: string;
|
|
209
|
-
synced: Record<string, number>;
|
|
210
|
-
errors: Array<{ type: string; id: string; error: string }>;
|
|
211
123
|
}> => {
|
|
212
124
|
const pending: Doc<"pendingOAuth"> | null = await ctx.runQuery(internal.garmin.private.getPendingOAuth, {
|
|
213
125
|
state: args.state,
|
|
@@ -262,140 +174,10 @@ export const completeGarminOAuth = action({
|
|
|
262
174
|
});
|
|
263
175
|
}
|
|
264
176
|
|
|
265
|
-
const now = Math.floor(Date.now() / 1000);
|
|
266
|
-
const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
|
|
267
|
-
const timeRange = {
|
|
268
|
-
uploadStartTimeInSeconds: thirtyDaysAgo,
|
|
269
|
-
uploadEndTimeInSeconds: now,
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
const result = await ctx.runAction(api.garmin.public.syncAllTypes, {
|
|
273
|
-
accessToken: tokenResult.access_token,
|
|
274
|
-
connectionId,
|
|
275
|
-
userId: pending.userId,
|
|
276
|
-
uploadStartTimeInSeconds: timeRange.uploadStartTimeInSeconds,
|
|
277
|
-
uploadEndTimeInSeconds: timeRange.uploadEndTimeInSeconds,
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
const _updated: null = await ctx.runMutation(api.public.updateConnection, {
|
|
281
|
-
connectionId,
|
|
282
|
-
lastDataUpdate: new Date().toISOString(),
|
|
283
|
-
});
|
|
284
|
-
|
|
285
177
|
return {
|
|
286
178
|
connectionId,
|
|
287
179
|
userId: pending.userId,
|
|
288
|
-
synced: result.synced,
|
|
289
|
-
errors: result.errors,
|
|
290
|
-
};
|
|
291
|
-
},
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Incremental Garmin sync for an already-connected user.
|
|
296
|
-
*
|
|
297
|
-
* Looks up the stored tokens, refreshes if expired, and syncs all data
|
|
298
|
-
* types for the specified time range (defaults to last 30 days).
|
|
299
|
-
*/
|
|
300
|
-
export const syncGarmin = action({
|
|
301
|
-
args: {
|
|
302
|
-
userId: v.string(),
|
|
303
|
-
clientId: v.string(),
|
|
304
|
-
clientSecret: v.string(),
|
|
305
|
-
startTimeInSeconds: v.optional(v.number()),
|
|
306
|
-
endTimeInSeconds: v.optional(v.number()),
|
|
307
|
-
},
|
|
308
|
-
handler: async (ctx, args): Promise<{
|
|
309
|
-
synced: Record<string, number>;
|
|
310
|
-
errors: Array<{ type: string; id: string; error: string }>;
|
|
311
|
-
}> => {
|
|
312
|
-
const connection: Doc<"connections"> | null = await ctx.runQuery(
|
|
313
|
-
internal.private.getConnectionByProvider,
|
|
314
|
-
{ userId: args.userId, provider: "GARMIN" },
|
|
315
|
-
);
|
|
316
|
-
if (!connection) {
|
|
317
|
-
throw new Error(
|
|
318
|
-
`No Garmin connection found for user "${args.userId}". ` +
|
|
319
|
-
"Call connectGarmin first.",
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
if (!connection.active) {
|
|
323
|
-
throw new Error(
|
|
324
|
-
`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
|
|
325
|
-
);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const connectionId = connection._id;
|
|
329
|
-
|
|
330
|
-
const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(internal.garmin.private.getTokens, {
|
|
331
|
-
connectionId,
|
|
332
|
-
});
|
|
333
|
-
if (!tokenDoc) {
|
|
334
|
-
throw new Error(
|
|
335
|
-
"No Garmin tokens found for this connection. " +
|
|
336
|
-
"The connection may have been created before token storage was available.",
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
let accessToken = tokenDoc.accessToken;
|
|
341
|
-
|
|
342
|
-
// Refresh the token if it's expired or about to expire
|
|
343
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
344
|
-
if (
|
|
345
|
-
tokenDoc.expiresAt &&
|
|
346
|
-
tokenDoc.refreshToken &&
|
|
347
|
-
nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
|
|
348
|
-
) {
|
|
349
|
-
const refreshed = await refreshToken({
|
|
350
|
-
clientId: args.clientId,
|
|
351
|
-
clientSecret: args.clientSecret,
|
|
352
|
-
refreshToken: tokenDoc.refreshToken,
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
accessToken = refreshed.access_token;
|
|
356
|
-
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
357
|
-
const _refreshed: null = await ctx.runMutation(internal.garmin.private.storeTokens, {
|
|
358
|
-
connectionId,
|
|
359
|
-
accessToken: refreshed.access_token,
|
|
360
|
-
refreshToken: refreshed.refresh_token,
|
|
361
|
-
expiresAt: newExpiresAt,
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
// Lazy backfill: resolve Garmin user ID if missing (for webhook mapping)
|
|
366
|
-
if (!connection.providerUserId) {
|
|
367
|
-
const wellnessClient = createWellnessClient(accessToken);
|
|
368
|
-
const { data: userIdData } = await sdkUserId({ client: wellnessClient });
|
|
369
|
-
const garminUserId = userIdData?.userId ?? null;
|
|
370
|
-
if (garminUserId) {
|
|
371
|
-
const _updated: null = await ctx.runMutation(api.public.updateConnection, {
|
|
372
|
-
connectionId,
|
|
373
|
-
providerUserId: garminUserId,
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
const now = Math.floor(Date.now() / 1000);
|
|
379
|
-
const timeRange = {
|
|
380
|
-
uploadStartTimeInSeconds:
|
|
381
|
-
args.startTimeInSeconds ?? now - DEFAULT_SYNC_DAYS * 86400,
|
|
382
|
-
uploadEndTimeInSeconds: args.endTimeInSeconds ?? now,
|
|
383
180
|
};
|
|
384
|
-
|
|
385
|
-
const result = await ctx.runAction(api.garmin.public.syncAllTypes, {
|
|
386
|
-
accessToken,
|
|
387
|
-
connectionId,
|
|
388
|
-
userId: args.userId,
|
|
389
|
-
uploadStartTimeInSeconds: timeRange.uploadStartTimeInSeconds,
|
|
390
|
-
uploadEndTimeInSeconds: timeRange.uploadEndTimeInSeconds,
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
const _updated: null = await ctx.runMutation(api.public.updateConnection, {
|
|
394
|
-
connectionId,
|
|
395
|
-
lastDataUpdate: new Date().toISOString(),
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
return result;
|
|
399
181
|
},
|
|
400
182
|
});
|
|
401
183
|
|
|
@@ -446,158 +228,687 @@ export const disconnectGarmin = action({
|
|
|
446
228
|
},
|
|
447
229
|
});
|
|
448
230
|
|
|
449
|
-
// ───
|
|
231
|
+
// ─── Pull ───────────────────────────────────────────────────────────────────
|
|
450
232
|
|
|
451
|
-
|
|
452
|
-
* Push a planned workout from Soma's DB to Garmin Connect.
|
|
453
|
-
*
|
|
454
|
-
* Reads the planned workout document, transforms it to Garmin Training API V2
|
|
455
|
-
* format, creates the workout at Garmin, and optionally schedules it if a
|
|
456
|
-
* `planned_date` is set in the metadata.
|
|
457
|
-
*
|
|
458
|
-
* Returns the Garmin workout ID and schedule ID (if scheduled).
|
|
459
|
-
*/
|
|
460
|
-
export const pushPlannedWorkout = action({
|
|
233
|
+
export const pullActivities = action({
|
|
461
234
|
args: {
|
|
462
235
|
userId: v.string(),
|
|
463
236
|
clientId: v.string(),
|
|
464
237
|
clientSecret: v.string(),
|
|
465
|
-
|
|
466
|
-
|
|
238
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
239
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
467
240
|
},
|
|
468
|
-
handler: async (ctx, args)
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
241
|
+
handler: async (ctx, args) => {
|
|
242
|
+
|
|
243
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
244
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
245
|
+
{
|
|
246
|
+
userId: args.userId,
|
|
247
|
+
clientId: args.clientId,
|
|
248
|
+
clientSecret: args.clientSecret,
|
|
249
|
+
},
|
|
472
250
|
);
|
|
473
|
-
if (!connection) {
|
|
474
|
-
throw new Error(
|
|
475
|
-
`No Garmin connection found for user "${args.userId}". ` +
|
|
476
|
-
"Call connectGarmin first.",
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
if (!connection.active) {
|
|
480
|
-
throw new Error(
|
|
481
|
-
`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
|
|
482
|
-
);
|
|
483
|
-
}
|
|
484
251
|
|
|
485
|
-
const
|
|
252
|
+
const timeRangeQuery = buildTimeRangeQuery(args, accessToken);
|
|
486
253
|
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
if (!tokenDoc) {
|
|
491
|
-
throw new Error(
|
|
492
|
-
"No Garmin tokens found for this connection. " +
|
|
493
|
-
"The connection may have been created before token storage was available.",
|
|
494
|
-
);
|
|
495
|
-
}
|
|
254
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
255
|
+
const synced = { activities: 0 };
|
|
256
|
+
const errors: Array<{ type: "activity"; id: string; error: string }> = [];
|
|
496
257
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
258
|
+
try {
|
|
259
|
+
const { data: activities, error } = await getActivities({
|
|
260
|
+
client: wellnessClient,
|
|
261
|
+
query: timeRangeQuery,
|
|
262
|
+
});
|
|
500
263
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
clientId: args.clientId,
|
|
505
|
-
clientSecret: args.clientSecret,
|
|
506
|
-
refreshToken: tokenDoc.refreshToken,
|
|
507
|
-
});
|
|
264
|
+
if (error || !activities) {
|
|
265
|
+
throw new Error(error ? JSON.stringify(error) : "No data");
|
|
266
|
+
}
|
|
508
267
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
268
|
+
for (const activity of activities) {
|
|
269
|
+
try {
|
|
270
|
+
const data = transformActivity(activity);
|
|
271
|
+
await ctx.runMutation(api.public.ingestActivity, {
|
|
272
|
+
connectionId,
|
|
273
|
+
userId: args.userId,
|
|
274
|
+
...data,
|
|
275
|
+
});
|
|
276
|
+
synced.activities++;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
errors.push({
|
|
279
|
+
type: "activity",
|
|
280
|
+
id: activity.summaryId ?? String(activity.activityId),
|
|
281
|
+
error: err instanceof Error ? err.message : String(err),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
errors.push({
|
|
287
|
+
type: "activity",
|
|
288
|
+
id: "fetch",
|
|
289
|
+
error: err instanceof Error ? err.message : String(err),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await ctx.runMutation(api.public.updateConnection, {
|
|
294
|
+
connectionId,
|
|
295
|
+
lastDataUpdate: new Date().toISOString(),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
return { synced, errors };
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
export const pullDailies = action({
|
|
303
|
+
args: {
|
|
304
|
+
userId: v.string(),
|
|
305
|
+
clientId: v.string(),
|
|
306
|
+
clientSecret: v.string(),
|
|
307
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
308
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
309
|
+
},
|
|
310
|
+
handler: async (ctx, args) => {
|
|
311
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
312
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
313
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
317
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
318
|
+
const synced = { dailies: 0 };
|
|
319
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const { data: dailies, error } = await getDailies({ client: wellnessClient, query });
|
|
323
|
+
if (error || !dailies) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
324
|
+
for (const daily of dailies) {
|
|
325
|
+
try {
|
|
326
|
+
const data = transformDailies(daily);
|
|
327
|
+
if (!data) continue;
|
|
328
|
+
await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
|
|
329
|
+
synced.dailies++;
|
|
330
|
+
} catch (err) {
|
|
331
|
+
errors.push({ type: "daily", id: daily.summaryId ?? daily.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
} catch (err) {
|
|
335
|
+
errors.push({ type: "daily", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
339
|
+
return { synced, errors };
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
export const pullSleep = action({
|
|
344
|
+
args: {
|
|
345
|
+
userId: v.string(),
|
|
346
|
+
clientId: v.string(),
|
|
347
|
+
clientSecret: v.string(),
|
|
348
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
349
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
350
|
+
},
|
|
351
|
+
handler: async (ctx, args) => {
|
|
352
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
353
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
354
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
358
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
359
|
+
const synced = { sleep: 0 };
|
|
360
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const { data: sleeps, error } = await getSleeps({ client: wellnessClient, query });
|
|
364
|
+
if (error || !sleeps) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
365
|
+
for (const sleep of sleeps) {
|
|
366
|
+
try {
|
|
367
|
+
const data = transformSleeps(sleep);
|
|
368
|
+
await ctx.runMutation(api.public.ingestSleep, { connectionId, userId: args.userId, ...data });
|
|
369
|
+
synced.sleep++;
|
|
370
|
+
} catch (err) {
|
|
371
|
+
errors.push({ type: "sleep", id: sleep.summaryId ?? sleep.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch (err) {
|
|
375
|
+
errors.push({ type: "sleep", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
379
|
+
return { synced, errors };
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
export const pullBody = action({
|
|
384
|
+
args: {
|
|
385
|
+
userId: v.string(),
|
|
386
|
+
clientId: v.string(),
|
|
387
|
+
clientSecret: v.string(),
|
|
388
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
389
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
390
|
+
},
|
|
391
|
+
handler: async (ctx, args) => {
|
|
392
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
393
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
394
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
398
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
399
|
+
const synced = { body: 0 };
|
|
400
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
401
|
+
|
|
402
|
+
try {
|
|
403
|
+
const { data: bodyComps, error } = await getBodyComps({ client: wellnessClient, query });
|
|
404
|
+
if (error || !bodyComps) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
405
|
+
for (const body of bodyComps) {
|
|
406
|
+
try {
|
|
407
|
+
const data = transformBodyComposition(body);
|
|
408
|
+
if (!data) continue;
|
|
409
|
+
await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
|
|
410
|
+
synced.body++;
|
|
411
|
+
} catch (err) {
|
|
412
|
+
errors.push({ type: "body", id: body.summaryId ?? String(body.measurementTimeInSeconds), error: err instanceof Error ? err.message : String(err) });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch (err) {
|
|
416
|
+
errors.push({ type: "body", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
420
|
+
return { synced, errors };
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
export const pullMenstruation = action({
|
|
425
|
+
args: {
|
|
426
|
+
userId: v.string(),
|
|
427
|
+
clientId: v.string(),
|
|
428
|
+
clientSecret: v.string(),
|
|
429
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
430
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
431
|
+
},
|
|
432
|
+
handler: async (ctx, args) => {
|
|
433
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
434
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
435
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
439
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
440
|
+
const synced = { menstruation: 0 };
|
|
441
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
442
|
+
|
|
443
|
+
try {
|
|
444
|
+
const { data: records, error } = await getMct({ client: wellnessClient, query });
|
|
445
|
+
if (error || !records) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
446
|
+
for (const record of records) {
|
|
447
|
+
try {
|
|
448
|
+
const data = transformMenstrualCycleTracking(record);
|
|
449
|
+
await ctx.runMutation(api.public.ingestMenstruation, { connectionId, userId: args.userId, ...data });
|
|
450
|
+
synced.menstruation++;
|
|
451
|
+
} catch (err) {
|
|
452
|
+
errors.push({ type: "menstruation", id: record.summaryId ?? record.periodStartDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
} catch (err) {
|
|
456
|
+
errors.push({ type: "menstruation", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
460
|
+
return { synced, errors };
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
export const pullBloodPressures = action({
|
|
465
|
+
args: {
|
|
466
|
+
userId: v.string(),
|
|
467
|
+
clientId: v.string(),
|
|
468
|
+
clientSecret: v.string(),
|
|
469
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
470
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
471
|
+
},
|
|
472
|
+
handler: async (ctx, args) => {
|
|
473
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
474
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
475
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
479
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
480
|
+
const synced = { bloodPressures: 0 };
|
|
481
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const { data: bpRecords, error } = await getBloodPressures({ client: wellnessClient, query });
|
|
485
|
+
if (error || !bpRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
486
|
+
for (const bp of bpRecords) {
|
|
487
|
+
try {
|
|
488
|
+
const data = transformBloodPressure(bp);
|
|
489
|
+
if (!data) continue;
|
|
490
|
+
await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
|
|
491
|
+
synced.bloodPressures++;
|
|
492
|
+
} catch (err) {
|
|
493
|
+
errors.push({ type: "bloodPressure", id: bp.summaryId ?? String(bp.measurementTimeInSeconds), error: err instanceof Error ? err.message : String(err) });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
} catch (err) {
|
|
497
|
+
errors.push({ type: "bloodPressure", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
501
|
+
return { synced, errors };
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
export const pullSkinTemperature = action({
|
|
506
|
+
args: {
|
|
507
|
+
userId: v.string(),
|
|
508
|
+
clientId: v.string(),
|
|
509
|
+
clientSecret: v.string(),
|
|
510
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
511
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
512
|
+
},
|
|
513
|
+
handler: async (ctx, args) => {
|
|
514
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
515
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
516
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
520
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
521
|
+
const synced = { skinTemp: 0 };
|
|
522
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
const { data: skinRecords, error } = await getSkinTemp({ client: wellnessClient, query });
|
|
526
|
+
if (error || !skinRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
527
|
+
for (const skin of skinRecords) {
|
|
528
|
+
try {
|
|
529
|
+
const data = transformSkinTemperature(skin);
|
|
530
|
+
if (!data) continue;
|
|
531
|
+
await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
|
|
532
|
+
synced.skinTemp++;
|
|
533
|
+
} catch (err) {
|
|
534
|
+
errors.push({ type: "skinTemp", id: skin.summaryId ?? skin.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} catch (err) {
|
|
538
|
+
errors.push({ type: "skinTemp", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
542
|
+
return { synced, errors };
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
export const pullUserMetrics = action({
|
|
547
|
+
args: {
|
|
548
|
+
userId: v.string(),
|
|
549
|
+
clientId: v.string(),
|
|
550
|
+
clientSecret: v.string(),
|
|
551
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
552
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
553
|
+
},
|
|
554
|
+
handler: async (ctx, args) => {
|
|
555
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
556
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
557
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
561
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
562
|
+
const synced = { userMetrics: 0 };
|
|
563
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
const { data: metricsRecords, error } = await getUserMetrics({ client: wellnessClient, query });
|
|
567
|
+
if (error || !metricsRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
568
|
+
for (const metrics of metricsRecords) {
|
|
569
|
+
try {
|
|
570
|
+
const data = transformUserMetrics(metrics);
|
|
571
|
+
if (!data) continue;
|
|
572
|
+
await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
|
|
573
|
+
synced.userMetrics++;
|
|
574
|
+
} catch (err) {
|
|
575
|
+
errors.push({ type: "userMetrics", id: metrics.summaryId ?? metrics.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
} catch (err) {
|
|
579
|
+
errors.push({ type: "userMetrics", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
583
|
+
return { synced, errors };
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
export const pullHRV = action({
|
|
588
|
+
args: {
|
|
589
|
+
userId: v.string(),
|
|
590
|
+
clientId: v.string(),
|
|
591
|
+
clientSecret: v.string(),
|
|
592
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
593
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
594
|
+
},
|
|
595
|
+
handler: async (ctx, args) => {
|
|
596
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
597
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
598
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
602
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
603
|
+
const synced = { hrv: 0 };
|
|
604
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
const { data: hrvRecords, error } = await getHrv({ client: wellnessClient, query });
|
|
608
|
+
if (error || !hrvRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
609
|
+
for (const hrv of hrvRecords) {
|
|
610
|
+
try {
|
|
611
|
+
const data = transformHRVSummary(hrv);
|
|
612
|
+
if (!data) continue;
|
|
613
|
+
await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
|
|
614
|
+
synced.hrv++;
|
|
615
|
+
} catch (err) {
|
|
616
|
+
errors.push({ type: "hrv", id: hrv.summaryId ?? hrv.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
} catch (err) {
|
|
620
|
+
errors.push({ type: "hrv", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
624
|
+
return { synced, errors };
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
export const pullStressDetails = action({
|
|
629
|
+
args: {
|
|
630
|
+
userId: v.string(),
|
|
631
|
+
clientId: v.string(),
|
|
632
|
+
clientSecret: v.string(),
|
|
633
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
634
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
635
|
+
},
|
|
636
|
+
handler: async (ctx, args) => {
|
|
637
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
638
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
639
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
643
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
644
|
+
const synced = { stressDetails: 0 };
|
|
645
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
const { data: stressRecords, error } = await getStressDetails({ client: wellnessClient, query });
|
|
649
|
+
if (error || !stressRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
650
|
+
for (const stress of stressRecords) {
|
|
651
|
+
try {
|
|
652
|
+
const data = transformStress(stress);
|
|
653
|
+
if (!data) continue;
|
|
654
|
+
await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
|
|
655
|
+
synced.stressDetails++;
|
|
656
|
+
} catch (err) {
|
|
657
|
+
errors.push({ type: "stressDetails", id: stress.summaryId ?? stress.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
} catch (err) {
|
|
661
|
+
errors.push({ type: "stressDetails", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
665
|
+
return { synced, errors };
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
export const pullPulseOx = action({
|
|
670
|
+
args: {
|
|
671
|
+
userId: v.string(),
|
|
672
|
+
clientId: v.string(),
|
|
673
|
+
clientSecret: v.string(),
|
|
674
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
675
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
676
|
+
},
|
|
677
|
+
handler: async (ctx, args) => {
|
|
678
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
679
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
680
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
684
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
685
|
+
const synced = { pulseOx: 0 };
|
|
686
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
687
|
+
|
|
688
|
+
try {
|
|
689
|
+
const { data: pulseOxRecords, error } = await getPulseox({ client: wellnessClient, query });
|
|
690
|
+
if (error || !pulseOxRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
691
|
+
for (const po of pulseOxRecords) {
|
|
692
|
+
try {
|
|
693
|
+
const data = transformPulseOx(po);
|
|
694
|
+
if (!data) continue;
|
|
695
|
+
await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
|
|
696
|
+
synced.pulseOx++;
|
|
697
|
+
} catch (err) {
|
|
698
|
+
errors.push({ type: "pulseOx", id: po.summaryId ?? po.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
} catch (err) {
|
|
702
|
+
errors.push({ type: "pulseOx", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
706
|
+
return { synced, errors };
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
export const pullRespiration = action({
|
|
711
|
+
args: {
|
|
712
|
+
userId: v.string(),
|
|
713
|
+
clientId: v.string(),
|
|
714
|
+
clientSecret: v.string(),
|
|
715
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
716
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
717
|
+
},
|
|
718
|
+
handler: async (ctx, args) => {
|
|
719
|
+
const { connectionId, accessToken } = await ctx.runAction(
|
|
720
|
+
internal.garmin.private.resolveConnectionAndAccessToken,
|
|
721
|
+
{ userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret },
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
const query = buildTimeRangeQuery(args, accessToken);
|
|
725
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
726
|
+
const synced = { respiration: 0 };
|
|
727
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
const { data: respRecords, error } = await getRespiration({ client: wellnessClient, query });
|
|
731
|
+
if (error || !respRecords) throw new Error(error ? JSON.stringify(error) : "No data");
|
|
732
|
+
for (const resp of respRecords) {
|
|
733
|
+
try {
|
|
734
|
+
const data = transformRespiration(resp);
|
|
735
|
+
if (!data) continue;
|
|
736
|
+
await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
|
|
737
|
+
synced.respiration++;
|
|
738
|
+
} catch (err) {
|
|
739
|
+
errors.push({ type: "respiration", id: resp.summaryId ?? "unknown", error: err instanceof Error ? err.message : String(err) });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
} catch (err) {
|
|
743
|
+
errors.push({ type: "respiration", id: "fetch", error: err instanceof Error ? err.message : String(err) });
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
|
|
747
|
+
return { synced, errors };
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
export const pullAll = action({
|
|
752
|
+
args: {
|
|
753
|
+
userId: v.string(),
|
|
754
|
+
clientId: v.string(),
|
|
755
|
+
clientSecret: v.string(),
|
|
756
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
757
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
758
|
+
},
|
|
759
|
+
handler: async (ctx, args) => {
|
|
760
|
+
const sharedArgs = {
|
|
761
|
+
userId: args.userId,
|
|
762
|
+
clientId: args.clientId,
|
|
763
|
+
clientSecret: args.clientSecret,
|
|
764
|
+
startTimeInSeconds: args.startTimeInSeconds,
|
|
765
|
+
endTimeInSeconds: args.endTimeInSeconds,
|
|
766
|
+
};
|
|
767
|
+
const pullFns = [
|
|
768
|
+
{ ref: api.garmin.public.pullActivities, name: "activities" },
|
|
769
|
+
{ ref: api.garmin.public.pullDailies, name: "dailies" },
|
|
770
|
+
{ ref: api.garmin.public.pullSleep, name: "sleep" },
|
|
771
|
+
{ ref: api.garmin.public.pullBody, name: "body" },
|
|
772
|
+
{ ref: api.garmin.public.pullMenstruation, name: "menstruation" },
|
|
773
|
+
{ ref: api.garmin.public.pullBloodPressures, name: "bloodPressures" },
|
|
774
|
+
{ ref: api.garmin.public.pullSkinTemperature, name: "skinTemp" },
|
|
775
|
+
{ ref: api.garmin.public.pullUserMetrics, name: "userMetrics" },
|
|
776
|
+
{ ref: api.garmin.public.pullHRV, name: "hrv" },
|
|
777
|
+
{ ref: api.garmin.public.pullStressDetails, name: "stressDetails" },
|
|
778
|
+
{ ref: api.garmin.public.pullPulseOx, name: "pulseOx" },
|
|
779
|
+
{ ref: api.garmin.public.pullRespiration, name: "respiration" },
|
|
780
|
+
];
|
|
781
|
+
const synced: Record<string, number> = {};
|
|
782
|
+
const errors: Array<{ type: string; id: string; error: string }> = [];
|
|
783
|
+
for (const { ref, name } of pullFns) {
|
|
784
|
+
try {
|
|
785
|
+
const result = await ctx.runAction(ref, sharedArgs);
|
|
786
|
+
Object.assign(synced, result.synced);
|
|
787
|
+
errors.push(...result.errors);
|
|
788
|
+
} catch (err) {
|
|
789
|
+
errors.push({
|
|
790
|
+
type: name,
|
|
791
|
+
id: "pull",
|
|
792
|
+
error: err instanceof Error ? err.message : String(err),
|
|
517
793
|
});
|
|
518
|
-
} catch (refreshErr) {
|
|
519
|
-
throw new Error(
|
|
520
|
-
`Garmin token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}. ` +
|
|
521
|
-
"The user may need to reconnect their Garmin account.",
|
|
522
|
-
);
|
|
523
794
|
}
|
|
524
795
|
}
|
|
796
|
+
return { synced, errors };
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
/**
|
|
800
|
+
* Incremental Garmin sync for an already-connected user.
|
|
801
|
+
*
|
|
802
|
+
* Looks up the stored tokens, refreshes if expired, and syncs all data
|
|
803
|
+
* types for the specified time range (defaults to last 30 days).
|
|
804
|
+
*/
|
|
525
805
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
806
|
+
export const syncGarmin = action({
|
|
807
|
+
args: {
|
|
808
|
+
userId: v.string(),
|
|
809
|
+
clientId: v.string(),
|
|
810
|
+
clientSecret: v.string(),
|
|
811
|
+
startTimeInSeconds: v.optional(v.number()),
|
|
812
|
+
endTimeInSeconds: v.optional(v.number()),
|
|
813
|
+
},
|
|
814
|
+
handler: async (ctx, args): Promise<{
|
|
815
|
+
synced: Record<string, number>;
|
|
816
|
+
errors: Array<{ type: string; id: string; error: string }>;
|
|
817
|
+
}> => {
|
|
818
|
+
const connection: Doc<"connections"> | null = await ctx.runQuery(
|
|
819
|
+
internal.private.getConnectionByProvider,
|
|
820
|
+
{ userId: args.userId, provider: "GARMIN" },
|
|
529
821
|
);
|
|
530
|
-
if (!
|
|
822
|
+
if (!connection) {
|
|
531
823
|
throw new Error(
|
|
532
|
-
`
|
|
824
|
+
`No Garmin connection found for user "${args.userId}". ` +
|
|
825
|
+
"Connect to Garmin first via getGarminAuthUrl.",
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
if (!connection.active) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
|
|
533
831
|
);
|
|
534
832
|
}
|
|
535
833
|
|
|
536
|
-
const
|
|
537
|
-
const garminWorkout = transformPlannedWorkoutToGarmin(
|
|
538
|
-
plannedWorkout,
|
|
539
|
-
providerName,
|
|
540
|
-
);
|
|
541
|
-
|
|
542
|
-
const trainingClient = createTrainingClient(accessToken);
|
|
834
|
+
const connectionId = connection._id;
|
|
543
835
|
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
body: garminWorkout,
|
|
836
|
+
const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(internal.garmin.private.getTokens, {
|
|
837
|
+
connectionId,
|
|
547
838
|
});
|
|
548
|
-
if (
|
|
839
|
+
if (!tokenDoc) {
|
|
549
840
|
throw new Error(
|
|
550
|
-
|
|
841
|
+
"No Garmin tokens found for this connection. " +
|
|
842
|
+
"The connection may have been created before token storage was available.",
|
|
551
843
|
);
|
|
552
844
|
}
|
|
553
845
|
|
|
554
|
-
|
|
555
|
-
throw new Error("Garmin API did not return a workoutId after creation.");
|
|
556
|
-
}
|
|
846
|
+
let accessToken = tokenDoc.accessToken;
|
|
557
847
|
|
|
558
|
-
|
|
848
|
+
// Refresh the token if it's expired or about to expire
|
|
849
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
850
|
+
if (
|
|
851
|
+
tokenDoc.expiresAt &&
|
|
852
|
+
tokenDoc.refreshToken &&
|
|
853
|
+
nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
|
|
854
|
+
) {
|
|
855
|
+
const refreshed = await refreshToken({
|
|
856
|
+
clientId: args.clientId,
|
|
857
|
+
clientSecret: args.clientSecret,
|
|
858
|
+
refreshToken: tokenDoc.refreshToken,
|
|
859
|
+
});
|
|
559
860
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
861
|
+
accessToken = refreshed.access_token;
|
|
862
|
+
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
863
|
+
const _refreshed: null = await ctx.runMutation(internal.garmin.private.storeTokens, {
|
|
864
|
+
connectionId,
|
|
865
|
+
accessToken: refreshed.access_token,
|
|
866
|
+
refreshToken: refreshed.refresh_token,
|
|
867
|
+
expiresAt: newExpiresAt,
|
|
565
868
|
});
|
|
566
|
-
if (scheduleError) {
|
|
567
|
-
throw new Error(
|
|
568
|
-
`Garmin API error creating schedule: ${JSON.stringify(scheduleError)}`,
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
|
-
garminScheduleId = scheduleId ?? null;
|
|
572
869
|
}
|
|
573
870
|
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
} as never);
|
|
871
|
+
// Lazy backfill: resolve Garmin user ID if missing (for webhook mapping)
|
|
872
|
+
if (!connection.providerUserId) {
|
|
873
|
+
const wellnessClient = createWellnessClient(accessToken);
|
|
874
|
+
const { data: userIdData } = await sdkUserId({ client: wellnessClient });
|
|
875
|
+
const garminUserId = userIdData?.userId ?? null;
|
|
876
|
+
if (garminUserId) {
|
|
877
|
+
const _updated: null = await ctx.runMutation(api.public.updateConnection, {
|
|
878
|
+
connectionId,
|
|
879
|
+
providerUserId: garminUserId,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
587
883
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
884
|
+
const now = Math.floor(Date.now() / 1000);
|
|
885
|
+
const timeRange = {
|
|
886
|
+
uploadStartTimeInSeconds:
|
|
887
|
+
args.startTimeInSeconds ?? now - DEFAULT_SYNC_DAYS * 86400,
|
|
888
|
+
uploadEndTimeInSeconds: args.endTimeInSeconds ?? now,
|
|
591
889
|
};
|
|
890
|
+
|
|
891
|
+
const result = await ctx.runAction(api.garmin.public.syncAllTypes, {
|
|
892
|
+
accessToken,
|
|
893
|
+
connectionId,
|
|
894
|
+
userId: args.userId,
|
|
895
|
+
uploadStartTimeInSeconds: timeRange.uploadStartTimeInSeconds,
|
|
896
|
+
uploadEndTimeInSeconds: timeRange.uploadEndTimeInSeconds,
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
const _updated: null = await ctx.runMutation(api.public.updateConnection, {
|
|
900
|
+
connectionId,
|
|
901
|
+
lastDataUpdate: new Date().toISOString(),
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
return result;
|
|
592
905
|
},
|
|
593
906
|
});
|
|
594
907
|
|
|
595
|
-
// ─── Sync Engine ────────────────────────────────────────────────────────────
|
|
596
|
-
|
|
597
908
|
/**
|
|
598
909
|
* Fetch and ingest all Garmin wellness data types for a time range.
|
|
599
910
|
*
|
|
600
|
-
* Called by
|
|
911
|
+
* Called by syncGarmin after obtaining a valid access token.
|
|
601
912
|
* after obtaining a valid access token.
|
|
602
913
|
*/
|
|
603
914
|
export const syncAllTypes = action({
|
|
@@ -947,3 +1258,149 @@ export const syncAllTypes = action({
|
|
|
947
1258
|
},
|
|
948
1259
|
});
|
|
949
1260
|
|
|
1261
|
+
// ─── Push ───────────────────────────────────────────────────────────────────
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Push a planned workout from Soma's DB to Garmin Connect.
|
|
1265
|
+
*
|
|
1266
|
+
* Reads the planned workout document, transforms it to Garmin Training API V2
|
|
1267
|
+
* format, creates the workout at Garmin, and optionally schedules it if a
|
|
1268
|
+
* `planned_date` is set in the metadata.
|
|
1269
|
+
*
|
|
1270
|
+
* Returns the Garmin workout ID and schedule ID (if scheduled).
|
|
1271
|
+
*/
|
|
1272
|
+
export const pushPlannedWorkout = action({
|
|
1273
|
+
args: {
|
|
1274
|
+
userId: v.string(),
|
|
1275
|
+
clientId: v.string(),
|
|
1276
|
+
clientSecret: v.string(),
|
|
1277
|
+
plannedWorkoutId: v.string(),
|
|
1278
|
+
workoutProvider: v.optional(v.string()),
|
|
1279
|
+
},
|
|
1280
|
+
handler: async (ctx, args): Promise<{ garminWorkoutId: number; garminScheduleId: number | null }> => {
|
|
1281
|
+
const connection: Doc<"connections"> | null = await ctx.runQuery(
|
|
1282
|
+
internal.private.getConnectionByProvider,
|
|
1283
|
+
{ userId: args.userId, provider: "GARMIN" },
|
|
1284
|
+
);
|
|
1285
|
+
if (!connection) {
|
|
1286
|
+
throw new Error(
|
|
1287
|
+
`No Garmin connection found for user "${args.userId}". ` +
|
|
1288
|
+
"Connect to Garmin first via getGarminAuthUrl.",
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
if (!connection.active) {
|
|
1292
|
+
throw new Error(
|
|
1293
|
+
`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
|
|
1294
|
+
);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const connectionId = connection._id;
|
|
1298
|
+
|
|
1299
|
+
const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(internal.garmin.private.getTokens, {
|
|
1300
|
+
connectionId,
|
|
1301
|
+
});
|
|
1302
|
+
if (!tokenDoc) {
|
|
1303
|
+
throw new Error(
|
|
1304
|
+
"No Garmin tokens found for this connection. " +
|
|
1305
|
+
"The connection may have been created before token storage was available.",
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// Always force-refresh the token for Training API calls to rule out
|
|
1310
|
+
// stale tokens (the initial sync swallows 401 errors silently).
|
|
1311
|
+
let accessToken = tokenDoc.accessToken;
|
|
1312
|
+
|
|
1313
|
+
if (tokenDoc.refreshToken) {
|
|
1314
|
+
try {
|
|
1315
|
+
const refreshed = await refreshToken({
|
|
1316
|
+
clientId: args.clientId,
|
|
1317
|
+
clientSecret: args.clientSecret,
|
|
1318
|
+
refreshToken: tokenDoc.refreshToken,
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
accessToken = refreshed.access_token;
|
|
1322
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
1323
|
+
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
1324
|
+
const _refreshed: null = await ctx.runMutation(internal.garmin.private.storeTokens, {
|
|
1325
|
+
connectionId,
|
|
1326
|
+
accessToken: refreshed.access_token,
|
|
1327
|
+
refreshToken: refreshed.refresh_token,
|
|
1328
|
+
expiresAt: newExpiresAt,
|
|
1329
|
+
});
|
|
1330
|
+
} catch (refreshErr) {
|
|
1331
|
+
throw new Error(
|
|
1332
|
+
`Garmin token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}. ` +
|
|
1333
|
+
"The user may need to reconnect their Garmin account.",
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const plannedWorkout: Doc<"plannedWorkouts"> | null = await ctx.runQuery(
|
|
1339
|
+
api.public.getPlannedWorkout,
|
|
1340
|
+
{ plannedWorkoutId: args.plannedWorkoutId as never },
|
|
1341
|
+
);
|
|
1342
|
+
if (!plannedWorkout) {
|
|
1343
|
+
throw new Error(
|
|
1344
|
+
`Planned workout "${args.plannedWorkoutId}" not found.`,
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const providerName = args.workoutProvider ?? "Soma";
|
|
1349
|
+
const garminWorkout = transformPlannedWorkoutToGarmin(
|
|
1350
|
+
plannedWorkout,
|
|
1351
|
+
providerName,
|
|
1352
|
+
);
|
|
1353
|
+
|
|
1354
|
+
const trainingClient = createTrainingClient(accessToken);
|
|
1355
|
+
|
|
1356
|
+
const { data: created, error: createError } = await sdkCreateWorkoutV2({
|
|
1357
|
+
client: trainingClient,
|
|
1358
|
+
body: garminWorkout,
|
|
1359
|
+
});
|
|
1360
|
+
if (createError || !created) {
|
|
1361
|
+
throw new Error(
|
|
1362
|
+
`Garmin API error creating workout: ${createError ? JSON.stringify(createError) : "No data"}`,
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (!created.workoutId) {
|
|
1367
|
+
throw new Error("Garmin API did not return a workoutId after creation.");
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
let garminScheduleId: number | null = null;
|
|
1371
|
+
|
|
1372
|
+
const plannedDate = plannedWorkout.metadata?.planned_date;
|
|
1373
|
+
if (plannedDate) {
|
|
1374
|
+
const { data: scheduleId, error: scheduleError } = await sdkCreateWorkoutSchedule({
|
|
1375
|
+
client: trainingClient,
|
|
1376
|
+
body: { workoutId: Number(created.workoutId), date: plannedDate },
|
|
1377
|
+
});
|
|
1378
|
+
if (scheduleError) {
|
|
1379
|
+
throw new Error(
|
|
1380
|
+
`Garmin API error creating schedule: ${JSON.stringify(scheduleError)}`,
|
|
1381
|
+
);
|
|
1382
|
+
}
|
|
1383
|
+
garminScheduleId = scheduleId ?? null;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Store the Garmin workout/schedule IDs back on the planned workout
|
|
1387
|
+
// so the host app can match completed activities to planned sessions.
|
|
1388
|
+
const _ingested: Id<"plannedWorkouts"> = await ctx.runMutation(api.public.ingestPlannedWorkout, {
|
|
1389
|
+
...plannedWorkout,
|
|
1390
|
+
_id: undefined,
|
|
1391
|
+
_creationTime: undefined,
|
|
1392
|
+
metadata: {
|
|
1393
|
+
...plannedWorkout.metadata,
|
|
1394
|
+
provider_workout_id: String(created.workoutId),
|
|
1395
|
+
provider_schedule_id:
|
|
1396
|
+
garminScheduleId != null ? String(garminScheduleId) : undefined,
|
|
1397
|
+
},
|
|
1398
|
+
} as never);
|
|
1399
|
+
|
|
1400
|
+
return {
|
|
1401
|
+
garminWorkoutId: created.workoutId,
|
|
1402
|
+
garminScheduleId,
|
|
1403
|
+
};
|
|
1404
|
+
},
|
|
1405
|
+
});
|
|
1406
|
+
|