@nativesquare/soma 0.6.0 → 0.7.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 +151 -53
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +162 -69
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +130 -17
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +61 -43
- package/dist/component/garmin.d.ts.map +1 -1
- package/dist/component/garmin.js +208 -122
- package/dist/component/garmin.js.map +1 -1
- package/dist/component/public.d.ts +363 -0
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +124 -0
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +7 -9
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +9 -10
- package/dist/component/schema.js.map +1 -1
- package/dist/component/strava.d.ts +0 -1
- package/dist/component/strava.d.ts.map +1 -1
- package/dist/component/strava.js +0 -1
- package/dist/component/strava.js.map +1 -1
- package/dist/component/validators/enums.d.ts +1 -1
- package/dist/garmin/auth.d.ts +55 -46
- package/dist/garmin/auth.d.ts.map +1 -1
- package/dist/garmin/auth.js +82 -122
- package/dist/garmin/auth.js.map +1 -1
- package/dist/garmin/client.d.ts +64 -17
- package/dist/garmin/client.d.ts.map +1 -1
- package/dist/garmin/client.js +143 -29
- package/dist/garmin/client.js.map +1 -1
- package/dist/garmin/index.d.ts +3 -3
- package/dist/garmin/index.d.ts.map +1 -1
- package/dist/garmin/index.js +4 -4
- package/dist/garmin/index.js.map +1 -1
- package/dist/garmin/plannedWorkout.d.ts +12 -0
- package/dist/garmin/plannedWorkout.d.ts.map +1 -0
- package/dist/garmin/plannedWorkout.js +267 -0
- package/dist/garmin/plannedWorkout.js.map +1 -0
- package/dist/garmin/types.d.ts +78 -6
- package/dist/garmin/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +147 -0
- package/src/component/_generated/component.ts +142 -0
- package/src/component/garmin.ts +118 -0
- package/src/component/public.ts +135 -0
- package/src/garmin/client.ts +164 -0
- package/src/garmin/plannedWorkout.ts +333 -0
- package/src/garmin/types.ts +143 -0
package/dist/component/garmin.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ─── Garmin Component Actions ────────────────────────────────────────────────
|
|
2
|
-
// Public actions that handle the full Garmin OAuth + sync lifecycle.
|
|
2
|
+
// Public actions that handle the full Garmin OAuth 2.0 PKCE + sync lifecycle.
|
|
3
3
|
// The host app calls these through the Soma class, which threads the
|
|
4
4
|
// credentials automatically from env vars or constructor config.
|
|
5
5
|
//
|
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
import { v } from "convex/values";
|
|
8
8
|
import { anyApi } from "convex/server";
|
|
9
9
|
import { action, internalMutation, internalQuery, } from "./_generated/server.js";
|
|
10
|
-
import {
|
|
10
|
+
import { generateCodeVerifier, generateCodeChallenge, generateState, buildAuthUrl, exchangeCode, refreshToken, } from "../garmin/auth.js";
|
|
11
11
|
import { GarminClient } from "../garmin/client.js";
|
|
12
12
|
import { transformActivity } from "../garmin/activity.js";
|
|
13
13
|
import { transformDaily } from "../garmin/daily.js";
|
|
14
14
|
import { transformSleep } from "../garmin/sleep.js";
|
|
15
15
|
import { transformBody } from "../garmin/body.js";
|
|
16
16
|
import { transformMenstruation } from "../garmin/menstruation.js";
|
|
17
|
+
import { transformPlannedWorkoutToGarmin } from "../garmin/plannedWorkout.js";
|
|
17
18
|
// Use anyApi to avoid circular type references between this file and _generated/api.ts.
|
|
18
19
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
20
|
const publicApi = anyApi;
|
|
@@ -21,14 +22,16 @@ const publicApi = anyApi;
|
|
|
21
22
|
const internalApi = anyApi;
|
|
22
23
|
// Default sync window: last 30 days
|
|
23
24
|
const DEFAULT_SYNC_DAYS = 30;
|
|
25
|
+
// Refresh buffer: refresh tokens 10 minutes before expiry
|
|
26
|
+
const REFRESH_BUFFER_SECONDS = 600;
|
|
24
27
|
// ─── Internal Pending OAuth CRUD ─────────────────────────────────────────────
|
|
25
|
-
// Temporary storage for in-progress Garmin OAuth
|
|
26
|
-
// Bridges
|
|
28
|
+
// Temporary storage for in-progress Garmin OAuth 2.0 PKCE flows.
|
|
29
|
+
// Bridges getGarminAuthUrl and completeGarminOAuth.
|
|
27
30
|
export const storePendingOAuth = internalMutation({
|
|
28
31
|
args: {
|
|
29
32
|
provider: v.string(),
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
state: v.string(),
|
|
34
|
+
codeVerifier: v.string(),
|
|
32
35
|
userId: v.string(),
|
|
33
36
|
},
|
|
34
37
|
returns: v.null(),
|
|
@@ -41,30 +44,30 @@ export const storePendingOAuth = internalMutation({
|
|
|
41
44
|
},
|
|
42
45
|
});
|
|
43
46
|
export const getPendingOAuth = internalQuery({
|
|
44
|
-
args: {
|
|
47
|
+
args: { state: v.string() },
|
|
45
48
|
returns: v.union(v.object({
|
|
46
49
|
_id: v.id("pendingOAuth"),
|
|
47
50
|
_creationTime: v.number(),
|
|
48
51
|
provider: v.string(),
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
state: v.string(),
|
|
53
|
+
codeVerifier: v.string(),
|
|
51
54
|
userId: v.string(),
|
|
52
55
|
createdAt: v.number(),
|
|
53
56
|
}), v.null()),
|
|
54
57
|
handler: async (ctx, args) => {
|
|
55
58
|
return await ctx.db
|
|
56
59
|
.query("pendingOAuth")
|
|
57
|
-
.withIndex("
|
|
60
|
+
.withIndex("by_state", (q) => q.eq("state", args.state))
|
|
58
61
|
.first();
|
|
59
62
|
},
|
|
60
63
|
});
|
|
61
64
|
export const deletePendingOAuth = internalMutation({
|
|
62
|
-
args: {
|
|
65
|
+
args: { state: v.string() },
|
|
63
66
|
returns: v.null(),
|
|
64
67
|
handler: async (ctx, args) => {
|
|
65
68
|
const pending = await ctx.db
|
|
66
69
|
.query("pendingOAuth")
|
|
67
|
-
.withIndex("
|
|
70
|
+
.withIndex("by_state", (q) => q.eq("state", args.state))
|
|
68
71
|
.first();
|
|
69
72
|
if (pending) {
|
|
70
73
|
await ctx.db.delete(pending._id);
|
|
@@ -74,14 +77,15 @@ export const deletePendingOAuth = internalMutation({
|
|
|
74
77
|
});
|
|
75
78
|
// ─── Internal Token CRUD ─────────────────────────────────────────────────────
|
|
76
79
|
/**
|
|
77
|
-
* Store OAuth
|
|
80
|
+
* Store OAuth 2.0 tokens for a Garmin connection.
|
|
78
81
|
* Upserts by connectionId — one token record per connection.
|
|
79
82
|
*/
|
|
80
83
|
export const storeTokens = internalMutation({
|
|
81
84
|
args: {
|
|
82
85
|
connectionId: v.id("connections"),
|
|
83
86
|
accessToken: v.string(),
|
|
84
|
-
|
|
87
|
+
refreshToken: v.string(),
|
|
88
|
+
expiresAt: v.number(),
|
|
85
89
|
},
|
|
86
90
|
returns: v.null(),
|
|
87
91
|
handler: async (ctx, args) => {
|
|
@@ -92,14 +96,16 @@ export const storeTokens = internalMutation({
|
|
|
92
96
|
if (existing) {
|
|
93
97
|
await ctx.db.patch(existing._id, {
|
|
94
98
|
accessToken: args.accessToken,
|
|
95
|
-
|
|
99
|
+
refreshToken: args.refreshToken,
|
|
100
|
+
expiresAt: args.expiresAt,
|
|
96
101
|
});
|
|
97
102
|
return null;
|
|
98
103
|
}
|
|
99
104
|
await ctx.db.insert("providerTokens", {
|
|
100
105
|
connectionId: args.connectionId,
|
|
101
106
|
accessToken: args.accessToken,
|
|
102
|
-
|
|
107
|
+
refreshToken: args.refreshToken,
|
|
108
|
+
expiresAt: args.expiresAt,
|
|
103
109
|
});
|
|
104
110
|
return null;
|
|
105
111
|
},
|
|
@@ -115,7 +121,6 @@ export const getTokens = internalQuery({
|
|
|
115
121
|
connectionId: v.id("connections"),
|
|
116
122
|
accessToken: v.string(),
|
|
117
123
|
refreshToken: v.optional(v.string()),
|
|
118
|
-
tokenSecret: v.optional(v.string()),
|
|
119
124
|
expiresAt: v.optional(v.number()),
|
|
120
125
|
}), v.null()),
|
|
121
126
|
handler: async (ctx, args) => {
|
|
@@ -144,64 +149,62 @@ export const deleteTokens = internalMutation({
|
|
|
144
149
|
});
|
|
145
150
|
// ─── Public Actions ──────────────────────────────────────────────────────────
|
|
146
151
|
/**
|
|
147
|
-
*
|
|
152
|
+
* Generate a Garmin OAuth 2.0 authorization URL with PKCE.
|
|
148
153
|
*
|
|
149
|
-
* If `userId` is provided, the
|
|
154
|
+
* If `userId` is provided, the PKCE code verifier and state are stored in the
|
|
150
155
|
* component's `pendingOAuth` table so that `completeGarminOAuth` can look
|
|
151
156
|
* them up automatically when the callback fires. This is the recommended
|
|
152
157
|
* flow when using `registerRoutes`.
|
|
153
158
|
*
|
|
154
|
-
* If `userId` is omitted, the host app must store the returned `
|
|
155
|
-
*
|
|
159
|
+
* If `userId` is omitted, the host app must store the returned `codeVerifier`
|
|
160
|
+
* itself and pass it to `connectGarmin` manually.
|
|
156
161
|
*/
|
|
157
|
-
export const
|
|
162
|
+
export const getGarminAuthUrl = action({
|
|
158
163
|
args: {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
callbackUrl: v.optional(v.string()),
|
|
164
|
+
clientId: v.string(),
|
|
165
|
+
redirectUri: v.optional(v.string()),
|
|
162
166
|
userId: v.optional(v.string()),
|
|
163
167
|
},
|
|
164
168
|
returns: v.object({
|
|
165
|
-
token: v.string(),
|
|
166
|
-
tokenSecret: v.string(),
|
|
167
169
|
authUrl: v.string(),
|
|
170
|
+
state: v.string(),
|
|
171
|
+
codeVerifier: v.string(),
|
|
168
172
|
}),
|
|
169
173
|
handler: async (ctx, args) => {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
const codeVerifier = generateCodeVerifier();
|
|
175
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
176
|
+
const state = generateState();
|
|
177
|
+
const authUrl = buildAuthUrl({
|
|
178
|
+
clientId: args.clientId,
|
|
179
|
+
codeChallenge,
|
|
180
|
+
redirectUri: args.redirectUri,
|
|
181
|
+
state,
|
|
174
182
|
});
|
|
175
183
|
if (args.userId) {
|
|
176
184
|
await ctx.runMutation(internalApi.garmin.storePendingOAuth, {
|
|
177
185
|
provider: "GARMIN",
|
|
178
|
-
|
|
179
|
-
|
|
186
|
+
state,
|
|
187
|
+
codeVerifier,
|
|
180
188
|
userId: args.userId,
|
|
181
189
|
});
|
|
182
190
|
}
|
|
183
|
-
return {
|
|
184
|
-
token: result.oauthToken,
|
|
185
|
-
tokenSecret: result.oauthTokenSecret,
|
|
186
|
-
authUrl: result.authUrl,
|
|
187
|
-
};
|
|
191
|
+
return { authUrl, state, codeVerifier };
|
|
188
192
|
},
|
|
189
193
|
});
|
|
190
194
|
/**
|
|
191
|
-
*
|
|
195
|
+
* Exchange an authorization code for tokens + initial sync.
|
|
192
196
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* the last 30 days of all data types.
|
|
197
|
+
* Used in the manual flow where the host app stores the code verifier
|
|
198
|
+
* and handles the callback itself.
|
|
196
199
|
*/
|
|
197
200
|
export const connectGarmin = action({
|
|
198
201
|
args: {
|
|
199
202
|
userId: v.string(),
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
203
|
+
clientId: v.string(),
|
|
204
|
+
clientSecret: v.string(),
|
|
205
|
+
code: v.string(),
|
|
206
|
+
codeVerifier: v.string(),
|
|
207
|
+
redirectUri: v.optional(v.string()),
|
|
205
208
|
},
|
|
206
209
|
returns: v.object({
|
|
207
210
|
connectionId: v.string(),
|
|
@@ -215,31 +218,26 @@ export const connectGarmin = action({
|
|
|
215
218
|
errors: v.array(v.object({ type: v.string(), id: v.string(), error: v.string() })),
|
|
216
219
|
}),
|
|
217
220
|
handler: async (ctx, args) => {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
verifier: args.verifier,
|
|
221
|
+
const tokenResult = await exchangeCode({
|
|
222
|
+
clientId: args.clientId,
|
|
223
|
+
clientSecret: args.clientSecret,
|
|
224
|
+
code: args.code,
|
|
225
|
+
codeVerifier: args.codeVerifier,
|
|
226
|
+
redirectUri: args.redirectUri,
|
|
225
227
|
});
|
|
226
|
-
// 2. Create/reactivate the Soma connection
|
|
227
228
|
const connectionId = await ctx.runMutation(publicApi.public.connect, {
|
|
228
229
|
userId: args.userId,
|
|
229
230
|
provider: "GARMIN",
|
|
230
231
|
});
|
|
231
|
-
|
|
232
|
+
const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
|
|
232
233
|
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
233
234
|
connectionId,
|
|
234
|
-
accessToken:
|
|
235
|
-
|
|
235
|
+
accessToken: tokenResult.access_token,
|
|
236
|
+
refreshToken: tokenResult.refresh_token,
|
|
237
|
+
expiresAt,
|
|
236
238
|
});
|
|
237
|
-
// 4. Sync last 30 days of all data types
|
|
238
239
|
const client = new GarminClient({
|
|
239
|
-
|
|
240
|
-
consumerSecret: args.consumerSecret,
|
|
241
|
-
accessToken: accessTokenResult.oauthToken,
|
|
242
|
-
tokenSecret: accessTokenResult.oauthTokenSecret,
|
|
240
|
+
accessToken: tokenResult.access_token,
|
|
243
241
|
});
|
|
244
242
|
const now = Math.floor(Date.now() / 1000);
|
|
245
243
|
const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
|
|
@@ -250,11 +248,8 @@ export const connectGarmin = action({
|
|
|
250
248
|
const result = await syncAllTypes(ctx, client, {
|
|
251
249
|
connectionId,
|
|
252
250
|
userId: args.userId,
|
|
253
|
-
consumerKey: args.consumerKey,
|
|
254
|
-
consumerSecret: args.consumerSecret,
|
|
255
251
|
timeRange,
|
|
256
252
|
});
|
|
257
|
-
// 5. Update lastDataUpdate timestamp
|
|
258
253
|
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
259
254
|
connectionId,
|
|
260
255
|
lastDataUpdate: new Date().toISOString(),
|
|
@@ -267,20 +262,21 @@ export const connectGarmin = action({
|
|
|
267
262
|
},
|
|
268
263
|
});
|
|
269
264
|
/**
|
|
270
|
-
* Complete a Garmin OAuth flow using stored pending state.
|
|
265
|
+
* Complete a Garmin OAuth 2.0 flow using stored pending state.
|
|
271
266
|
*
|
|
272
267
|
* Used by `registerRoutes` — the callback handler calls this with the
|
|
273
|
-
* `
|
|
274
|
-
*
|
|
275
|
-
* exchanges for
|
|
276
|
-
*
|
|
268
|
+
* `code` and `state` from the redirect. The action looks up the pending
|
|
269
|
+
* state (codeVerifier, userId) stored during `getGarminAuthUrl`,
|
|
270
|
+
* exchanges for tokens, creates the connection, syncs data, and
|
|
271
|
+
* cleans up the pending entry.
|
|
277
272
|
*/
|
|
278
273
|
export const completeGarminOAuth = action({
|
|
279
274
|
args: {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
275
|
+
code: v.string(),
|
|
276
|
+
state: v.string(),
|
|
277
|
+
clientId: v.string(),
|
|
278
|
+
clientSecret: v.string(),
|
|
279
|
+
redirectUri: v.optional(v.string()),
|
|
284
280
|
},
|
|
285
281
|
returns: v.object({
|
|
286
282
|
connectionId: v.string(),
|
|
@@ -294,43 +290,36 @@ export const completeGarminOAuth = action({
|
|
|
294
290
|
errors: v.array(v.object({ type: v.string(), id: v.string(), error: v.string() })),
|
|
295
291
|
}),
|
|
296
292
|
handler: async (ctx, args) => {
|
|
297
|
-
// 1. Look up pending state
|
|
298
293
|
const pending = await ctx.runQuery(internalApi.garmin.getPendingOAuth, {
|
|
299
|
-
|
|
294
|
+
state: args.state,
|
|
300
295
|
});
|
|
301
296
|
if (!pending) {
|
|
302
|
-
throw new Error("No pending Garmin OAuth state found for this
|
|
303
|
-
"The
|
|
297
|
+
throw new Error("No pending Garmin OAuth state found for this state parameter. " +
|
|
298
|
+
"The authorization may have expired or was already used.");
|
|
304
299
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
verifier: args.oauthVerifier,
|
|
300
|
+
const tokenResult = await exchangeCode({
|
|
301
|
+
clientId: args.clientId,
|
|
302
|
+
clientSecret: args.clientSecret,
|
|
303
|
+
code: args.code,
|
|
304
|
+
codeVerifier: pending.codeVerifier,
|
|
305
|
+
redirectUri: args.redirectUri,
|
|
312
306
|
});
|
|
313
|
-
// 3. Delete pending state (no longer needed)
|
|
314
307
|
await ctx.runMutation(internalApi.garmin.deletePendingOAuth, {
|
|
315
|
-
|
|
308
|
+
state: args.state,
|
|
316
309
|
});
|
|
317
|
-
// 4. Create/reactivate the Soma connection
|
|
318
310
|
const connectionId = await ctx.runMutation(publicApi.public.connect, {
|
|
319
311
|
userId: pending.userId,
|
|
320
312
|
provider: "GARMIN",
|
|
321
313
|
});
|
|
322
|
-
|
|
314
|
+
const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
|
|
323
315
|
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
324
316
|
connectionId,
|
|
325
|
-
accessToken:
|
|
326
|
-
|
|
317
|
+
accessToken: tokenResult.access_token,
|
|
318
|
+
refreshToken: tokenResult.refresh_token,
|
|
319
|
+
expiresAt,
|
|
327
320
|
});
|
|
328
|
-
// 6. Sync last 30 days of all data types
|
|
329
321
|
const client = new GarminClient({
|
|
330
|
-
|
|
331
|
-
consumerSecret: args.consumerSecret,
|
|
332
|
-
accessToken: accessTokenResult.oauthToken,
|
|
333
|
-
tokenSecret: accessTokenResult.oauthTokenSecret,
|
|
322
|
+
accessToken: tokenResult.access_token,
|
|
334
323
|
});
|
|
335
324
|
const now = Math.floor(Date.now() / 1000);
|
|
336
325
|
const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
|
|
@@ -341,11 +330,8 @@ export const completeGarminOAuth = action({
|
|
|
341
330
|
const result = await syncAllTypes(ctx, client, {
|
|
342
331
|
connectionId,
|
|
343
332
|
userId: pending.userId,
|
|
344
|
-
consumerKey: args.consumerKey,
|
|
345
|
-
consumerSecret: args.consumerSecret,
|
|
346
333
|
timeRange,
|
|
347
334
|
});
|
|
348
|
-
// 7. Update lastDataUpdate timestamp
|
|
349
335
|
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
350
336
|
connectionId,
|
|
351
337
|
lastDataUpdate: new Date().toISOString(),
|
|
@@ -360,14 +346,14 @@ export const completeGarminOAuth = action({
|
|
|
360
346
|
/**
|
|
361
347
|
* Incremental Garmin sync for an already-connected user.
|
|
362
348
|
*
|
|
363
|
-
* Looks up the stored tokens and syncs all data
|
|
364
|
-
* time range (defaults to last 30 days).
|
|
349
|
+
* Looks up the stored tokens, refreshes if expired, and syncs all data
|
|
350
|
+
* types for the specified time range (defaults to last 30 days).
|
|
365
351
|
*/
|
|
366
352
|
export const syncGarmin = action({
|
|
367
353
|
args: {
|
|
368
354
|
userId: v.string(),
|
|
369
|
-
|
|
370
|
-
|
|
355
|
+
clientId: v.string(),
|
|
356
|
+
clientSecret: v.string(),
|
|
371
357
|
startTimeInSeconds: v.optional(v.number()),
|
|
372
358
|
endTimeInSeconds: v.optional(v.number()),
|
|
373
359
|
},
|
|
@@ -382,7 +368,6 @@ export const syncGarmin = action({
|
|
|
382
368
|
errors: v.array(v.object({ type: v.string(), id: v.string(), error: v.string() })),
|
|
383
369
|
}),
|
|
384
370
|
handler: async (ctx, args) => {
|
|
385
|
-
// 1. Look up connection
|
|
386
371
|
const connection = await ctx.runQuery(internalApi.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
|
|
387
372
|
if (!connection) {
|
|
388
373
|
throw new Error(`No Garmin connection found for user "${args.userId}". ` +
|
|
@@ -392,21 +377,34 @@ export const syncGarmin = action({
|
|
|
392
377
|
throw new Error(`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`);
|
|
393
378
|
}
|
|
394
379
|
const connectionId = connection._id;
|
|
395
|
-
// 2. Get stored tokens
|
|
396
380
|
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
397
381
|
connectionId,
|
|
398
382
|
});
|
|
399
|
-
if (!tokenDoc
|
|
383
|
+
if (!tokenDoc) {
|
|
400
384
|
throw new Error("No Garmin tokens found for this connection. " +
|
|
401
385
|
"The connection may have been created before token storage was available.");
|
|
402
386
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
387
|
+
let accessToken = tokenDoc.accessToken;
|
|
388
|
+
// Refresh the token if it's expired or about to expire
|
|
389
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
390
|
+
if (tokenDoc.expiresAt &&
|
|
391
|
+
tokenDoc.refreshToken &&
|
|
392
|
+
nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS) {
|
|
393
|
+
const refreshed = await refreshToken({
|
|
394
|
+
clientId: args.clientId,
|
|
395
|
+
clientSecret: args.clientSecret,
|
|
396
|
+
refreshToken: tokenDoc.refreshToken,
|
|
397
|
+
});
|
|
398
|
+
accessToken = refreshed.access_token;
|
|
399
|
+
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
400
|
+
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
401
|
+
connectionId,
|
|
402
|
+
accessToken: refreshed.access_token,
|
|
403
|
+
refreshToken: refreshed.refresh_token,
|
|
404
|
+
expiresAt: newExpiresAt,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
const client = new GarminClient({ accessToken });
|
|
410
408
|
const now = Math.floor(Date.now() / 1000);
|
|
411
409
|
const timeRange = {
|
|
412
410
|
uploadStartTimeInSeconds: args.startTimeInSeconds ?? now - DEFAULT_SYNC_DAYS * 86400,
|
|
@@ -415,11 +413,8 @@ export const syncGarmin = action({
|
|
|
415
413
|
const result = await syncAllTypes(ctx, client, {
|
|
416
414
|
connectionId,
|
|
417
415
|
userId: args.userId,
|
|
418
|
-
consumerKey: args.consumerKey,
|
|
419
|
-
consumerSecret: args.consumerSecret,
|
|
420
416
|
timeRange,
|
|
421
417
|
});
|
|
422
|
-
// 4. Update lastDataUpdate timestamp
|
|
423
418
|
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
424
419
|
connectionId,
|
|
425
420
|
lastDataUpdate: new Date().toISOString(),
|
|
@@ -430,8 +425,8 @@ export const syncGarmin = action({
|
|
|
430
425
|
/**
|
|
431
426
|
* Disconnect a user from Garmin.
|
|
432
427
|
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
428
|
+
* Deregisters the user via the Garmin API (best-effort), deletes stored
|
|
429
|
+
* tokens, and sets the connection to inactive.
|
|
435
430
|
*/
|
|
436
431
|
export const disconnectGarmin = action({
|
|
437
432
|
args: {
|
|
@@ -439,15 +434,25 @@ export const disconnectGarmin = action({
|
|
|
439
434
|
},
|
|
440
435
|
returns: v.null(),
|
|
441
436
|
handler: async (ctx, args) => {
|
|
442
|
-
// 1. Look up connection
|
|
443
437
|
const connection = await ctx.runQuery(internalApi.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
|
|
444
438
|
if (!connection) {
|
|
445
439
|
throw new Error(`No Garmin connection found for user "${args.userId}".`);
|
|
446
440
|
}
|
|
447
441
|
const connectionId = connection._id;
|
|
448
|
-
//
|
|
442
|
+
// Best-effort: deregister user at Garmin
|
|
443
|
+
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
444
|
+
connectionId,
|
|
445
|
+
});
|
|
446
|
+
if (tokenDoc) {
|
|
447
|
+
try {
|
|
448
|
+
const client = new GarminClient({ accessToken: tokenDoc.accessToken });
|
|
449
|
+
await client.deleteUserRegistration();
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// Deregistration is best-effort; proceed with local cleanup
|
|
453
|
+
}
|
|
454
|
+
}
|
|
449
455
|
await ctx.runMutation(internalApi.garmin.deleteTokens, { connectionId });
|
|
450
|
-
// 3. Set connection inactive
|
|
451
456
|
await ctx.runMutation(publicApi.public.disconnect, {
|
|
452
457
|
userId: args.userId,
|
|
453
458
|
provider: "GARMIN",
|
|
@@ -455,6 +460,87 @@ export const disconnectGarmin = action({
|
|
|
455
460
|
return null;
|
|
456
461
|
},
|
|
457
462
|
});
|
|
463
|
+
// ─── Training API ────────────────────────────────────────────────────────────
|
|
464
|
+
/**
|
|
465
|
+
* Push a planned workout from Soma's DB to Garmin Connect.
|
|
466
|
+
*
|
|
467
|
+
* Reads the planned workout document, transforms it to Garmin Training API V2
|
|
468
|
+
* format, creates the workout at Garmin, and optionally schedules it if a
|
|
469
|
+
* `planned_date` is set in the metadata.
|
|
470
|
+
*
|
|
471
|
+
* Returns the Garmin workout ID and schedule ID (if scheduled).
|
|
472
|
+
*/
|
|
473
|
+
export const pushPlannedWorkout = action({
|
|
474
|
+
args: {
|
|
475
|
+
userId: v.string(),
|
|
476
|
+
clientId: v.string(),
|
|
477
|
+
clientSecret: v.string(),
|
|
478
|
+
plannedWorkoutId: v.string(),
|
|
479
|
+
workoutProvider: v.optional(v.string()),
|
|
480
|
+
},
|
|
481
|
+
returns: v.object({
|
|
482
|
+
garminWorkoutId: v.number(),
|
|
483
|
+
garminScheduleId: v.union(v.number(), v.null()),
|
|
484
|
+
}),
|
|
485
|
+
handler: async (ctx, args) => {
|
|
486
|
+
const connection = await ctx.runQuery(internalApi.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
|
|
487
|
+
if (!connection) {
|
|
488
|
+
throw new Error(`No Garmin connection found for user "${args.userId}". ` +
|
|
489
|
+
"Call connectGarmin first.");
|
|
490
|
+
}
|
|
491
|
+
if (!connection.active) {
|
|
492
|
+
throw new Error(`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`);
|
|
493
|
+
}
|
|
494
|
+
const connectionId = connection._id;
|
|
495
|
+
const tokenDoc = await ctx.runQuery(internalApi.garmin.getTokens, {
|
|
496
|
+
connectionId,
|
|
497
|
+
});
|
|
498
|
+
if (!tokenDoc) {
|
|
499
|
+
throw new Error("No Garmin tokens found for this connection. " +
|
|
500
|
+
"The connection may have been created before token storage was available.");
|
|
501
|
+
}
|
|
502
|
+
let accessToken = tokenDoc.accessToken;
|
|
503
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
504
|
+
if (tokenDoc.expiresAt &&
|
|
505
|
+
tokenDoc.refreshToken &&
|
|
506
|
+
nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS) {
|
|
507
|
+
const refreshed = await refreshToken({
|
|
508
|
+
clientId: args.clientId,
|
|
509
|
+
clientSecret: args.clientSecret,
|
|
510
|
+
refreshToken: tokenDoc.refreshToken,
|
|
511
|
+
});
|
|
512
|
+
accessToken = refreshed.access_token;
|
|
513
|
+
const newExpiresAt = nowSeconds + refreshed.expires_in;
|
|
514
|
+
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
515
|
+
connectionId,
|
|
516
|
+
accessToken: refreshed.access_token,
|
|
517
|
+
refreshToken: refreshed.refresh_token,
|
|
518
|
+
expiresAt: newExpiresAt,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
const plannedWorkout = await ctx.runQuery(publicApi.public.getPlannedWorkout, { plannedWorkoutId: args.plannedWorkoutId });
|
|
522
|
+
if (!plannedWorkout) {
|
|
523
|
+
throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
|
|
524
|
+
}
|
|
525
|
+
const providerName = args.workoutProvider ?? "Soma";
|
|
526
|
+
const garminWorkout = transformPlannedWorkoutToGarmin(plannedWorkout, providerName);
|
|
527
|
+
const client = new GarminClient({ accessToken });
|
|
528
|
+
const created = await client.createWorkout(garminWorkout);
|
|
529
|
+
if (!created.workoutId) {
|
|
530
|
+
throw new Error("Garmin API did not return a workoutId after creation.");
|
|
531
|
+
}
|
|
532
|
+
let garminScheduleId = null;
|
|
533
|
+
const plannedDate = plannedWorkout.metadata?.planned_date;
|
|
534
|
+
if (plannedDate) {
|
|
535
|
+
const schedule = await client.createSchedule(created.workoutId, plannedDate);
|
|
536
|
+
garminScheduleId = schedule.scheduleId ?? null;
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
garminWorkoutId: created.workoutId,
|
|
540
|
+
garminScheduleId,
|
|
541
|
+
};
|
|
542
|
+
},
|
|
543
|
+
});
|
|
458
544
|
async function syncAllTypes(ctx, client, config) {
|
|
459
545
|
const { connectionId, userId, timeRange } = config;
|
|
460
546
|
const synced = { activities: 0, dailies: 0, sleep: 0, body: 0, menstruation: 0 };
|