@nativesquare/soma 0.9.4 → 0.10.1

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.
Files changed (203) hide show
  1. package/dist/client/index.d.ts +124 -144
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +157 -134
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/api.d.ts +18 -0
  6. package/dist/component/_generated/api.d.ts.map +1 -1
  7. package/dist/component/_generated/api.js.map +1 -1
  8. package/dist/component/_generated/component.d.ts +113 -22
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/garmin/auth.d.ts +0 -4
  11. package/dist/component/garmin/auth.d.ts.map +1 -1
  12. package/dist/component/garmin/auth.js +0 -8
  13. package/dist/component/garmin/auth.js.map +1 -1
  14. package/dist/component/garmin/private.d.ts +10 -1
  15. package/dist/component/garmin/private.d.ts.map +1 -1
  16. package/dist/component/garmin/private.js +49 -9
  17. package/dist/component/garmin/private.js.map +1 -1
  18. package/dist/component/garmin/public.d.ts +237 -62
  19. package/dist/component/garmin/public.d.ts.map +1 -1
  20. package/dist/component/garmin/public.js +689 -254
  21. package/dist/component/garmin/public.js.map +1 -1
  22. package/dist/component/garmin/utils.d.ts +8 -0
  23. package/dist/component/garmin/utils.d.ts.map +1 -1
  24. package/dist/component/garmin/utils.js +9 -0
  25. package/dist/component/garmin/utils.js.map +1 -1
  26. package/dist/component/schema.d.ts +2 -2
  27. package/dist/component/schema.d.ts.map +1 -1
  28. package/dist/component/schema.js +5 -3
  29. package/dist/component/schema.js.map +1 -1
  30. package/dist/{strava → component/strava}/auth.d.ts +15 -48
  31. package/dist/component/strava/auth.d.ts.map +1 -0
  32. package/dist/{strava → component/strava}/auth.js +4 -39
  33. package/dist/component/strava/auth.js.map +1 -0
  34. package/dist/component/strava/client.d.ts +8 -0
  35. package/dist/component/strava/client.d.ts.map +1 -0
  36. package/dist/component/strava/client.js +18 -0
  37. package/dist/component/strava/client.js.map +1 -0
  38. package/dist/component/strava/private.d.ts +19 -0
  39. package/dist/component/strava/private.d.ts.map +1 -1
  40. package/dist/component/strava/private.js +52 -2
  41. package/dist/component/strava/private.js.map +1 -1
  42. package/dist/component/strava/public.d.ts +54 -19
  43. package/dist/component/strava/public.d.ts.map +1 -1
  44. package/dist/component/strava/public.js +159 -109
  45. package/dist/component/strava/public.js.map +1 -1
  46. package/dist/component/strava/transform/activity.d.ts +19 -0
  47. package/dist/component/strava/transform/activity.d.ts.map +1 -0
  48. package/dist/{strava → component/strava/transform}/activity.js +31 -45
  49. package/dist/component/strava/transform/activity.js.map +1 -0
  50. package/dist/{strava → component/strava/transform}/athlete.d.ts +4 -10
  51. package/dist/component/strava/transform/athlete.d.ts.map +1 -0
  52. package/dist/{strava → component/strava/transform}/athlete.js +2 -8
  53. package/dist/component/strava/transform/athlete.js.map +1 -0
  54. package/dist/component/strava/transform/maps/sportType.d.ts +7 -0
  55. package/dist/component/strava/transform/maps/sportType.d.ts.map +1 -0
  56. package/dist/{strava/maps/sport-type.js → component/strava/transform/maps/sportType.js} +4 -2
  57. package/dist/component/strava/transform/maps/sportType.js.map +1 -0
  58. package/dist/component/strava/types/stravaApi/client/client.gen.d.ts +3 -0
  59. package/dist/component/strava/types/stravaApi/client/client.gen.d.ts.map +1 -0
  60. package/dist/component/strava/types/stravaApi/client/client.gen.js +236 -0
  61. package/dist/component/strava/types/stravaApi/client/client.gen.js.map +1 -0
  62. package/dist/component/strava/types/stravaApi/client/index.d.ts +9 -0
  63. package/dist/component/strava/types/stravaApi/client/index.d.ts.map +1 -0
  64. package/dist/component/strava/types/stravaApi/client/index.js +7 -0
  65. package/dist/component/strava/types/stravaApi/client/index.js.map +1 -0
  66. package/dist/component/strava/types/stravaApi/client/types.gen.d.ts +118 -0
  67. package/dist/component/strava/types/stravaApi/client/types.gen.d.ts.map +1 -0
  68. package/dist/component/strava/types/stravaApi/client/types.gen.js +3 -0
  69. package/dist/component/strava/types/stravaApi/client/types.gen.js.map +1 -0
  70. package/dist/component/strava/types/stravaApi/client/utils.gen.d.ts +34 -0
  71. package/dist/component/strava/types/stravaApi/client/utils.gen.d.ts.map +1 -0
  72. package/dist/component/strava/types/stravaApi/client/utils.gen.js +229 -0
  73. package/dist/component/strava/types/stravaApi/client/utils.gen.js.map +1 -0
  74. package/dist/component/strava/types/stravaApi/client.gen.d.ts +13 -0
  75. package/dist/component/strava/types/stravaApi/client.gen.d.ts.map +1 -0
  76. package/dist/component/strava/types/stravaApi/client.gen.js +4 -0
  77. package/dist/component/strava/types/stravaApi/client.gen.js.map +1 -0
  78. package/dist/component/strava/types/stravaApi/core/auth.gen.d.ts +19 -0
  79. package/dist/component/strava/types/stravaApi/core/auth.gen.d.ts.map +1 -0
  80. package/dist/component/strava/types/stravaApi/core/auth.gen.js +15 -0
  81. package/dist/component/strava/types/stravaApi/core/auth.gen.js.map +1 -0
  82. package/dist/component/strava/types/stravaApi/core/bodySerializer.gen.d.ts +26 -0
  83. package/dist/component/strava/types/stravaApi/core/bodySerializer.gen.d.ts.map +1 -0
  84. package/dist/component/strava/types/stravaApi/core/bodySerializer.gen.js +58 -0
  85. package/dist/component/strava/types/stravaApi/core/bodySerializer.gen.js.map +1 -0
  86. package/dist/component/strava/types/stravaApi/core/params.gen.d.ts +44 -0
  87. package/dist/component/strava/types/stravaApi/core/params.gen.d.ts.map +1 -0
  88. package/dist/component/strava/types/stravaApi/core/params.gen.js +101 -0
  89. package/dist/component/strava/types/stravaApi/core/params.gen.js.map +1 -0
  90. package/dist/component/strava/types/stravaApi/core/pathSerializer.gen.d.ts +34 -0
  91. package/dist/component/strava/types/stravaApi/core/pathSerializer.gen.d.ts.map +1 -0
  92. package/dist/component/strava/types/stravaApi/core/pathSerializer.gen.js +107 -0
  93. package/dist/component/strava/types/stravaApi/core/pathSerializer.gen.js.map +1 -0
  94. package/dist/component/strava/types/stravaApi/core/queryKeySerializer.gen.d.ts +19 -0
  95. package/dist/component/strava/types/stravaApi/core/queryKeySerializer.gen.d.ts.map +1 -0
  96. package/dist/component/strava/types/stravaApi/core/queryKeySerializer.gen.js +93 -0
  97. package/dist/component/strava/types/stravaApi/core/queryKeySerializer.gen.js.map +1 -0
  98. package/dist/component/strava/types/stravaApi/core/serverSentEvents.gen.d.ts +72 -0
  99. package/dist/component/strava/types/stravaApi/core/serverSentEvents.gen.d.ts.map +1 -0
  100. package/dist/component/strava/types/stravaApi/core/serverSentEvents.gen.js +134 -0
  101. package/dist/component/strava/types/stravaApi/core/serverSentEvents.gen.js.map +1 -0
  102. package/dist/component/strava/types/stravaApi/core/types.gen.d.ts +79 -0
  103. package/dist/component/strava/types/stravaApi/core/types.gen.d.ts.map +1 -0
  104. package/dist/component/strava/types/stravaApi/core/types.gen.js +3 -0
  105. package/dist/component/strava/types/stravaApi/core/types.gen.js.map +1 -0
  106. package/dist/component/strava/types/stravaApi/core/utils.gen.d.ts +20 -0
  107. package/dist/component/strava/types/stravaApi/core/utils.gen.d.ts.map +1 -0
  108. package/dist/component/strava/types/stravaApi/core/utils.gen.js +88 -0
  109. package/dist/component/strava/types/stravaApi/core/utils.gen.js.map +1 -0
  110. package/dist/component/strava/types/stravaApi/index.d.ts +3 -0
  111. package/dist/component/strava/types/stravaApi/index.d.ts.map +1 -0
  112. package/dist/component/strava/types/stravaApi/index.js +3 -0
  113. package/dist/component/strava/types/stravaApi/index.js.map +1 -0
  114. package/dist/component/strava/types/stravaApi/sdk.gen.d.ts +224 -0
  115. package/dist/component/strava/types/stravaApi/sdk.gen.d.ts.map +1 -0
  116. package/dist/component/strava/types/stravaApi/sdk.gen.js +361 -0
  117. package/dist/component/strava/types/stravaApi/sdk.gen.js.map +1 -0
  118. package/dist/component/strava/types/stravaApi/types.gen.d.ts +2209 -0
  119. package/dist/component/strava/types/stravaApi/types.gen.d.ts.map +1 -0
  120. package/dist/component/strava/types/stravaApi/types.gen.js +3 -0
  121. package/dist/component/strava/types/stravaApi/types.gen.js.map +1 -0
  122. package/dist/component/strava/types/stravaApi/zod.gen.d.ts +5332 -0
  123. package/dist/component/strava/types/stravaApi/zod.gen.d.ts.map +1 -0
  124. package/dist/component/strava/types/stravaApi/zod.gen.js +1009 -0
  125. package/dist/component/strava/types/stravaApi/zod.gen.js.map +1 -0
  126. package/dist/component/strava/utils.d.ts +15 -0
  127. package/dist/component/strava/utils.d.ts.map +1 -0
  128. package/dist/component/strava/utils.js +36 -0
  129. package/dist/component/strava/utils.js.map +1 -0
  130. package/dist/component/utils.d.ts +5 -0
  131. package/dist/component/utils.d.ts.map +1 -0
  132. package/dist/component/utils.js +11 -0
  133. package/dist/component/utils.js.map +1 -0
  134. package/package.json +131 -130
  135. package/src/client/index.ts +285 -164
  136. package/src/component/_generated/api.ts +18 -0
  137. package/src/component/_generated/component.ts +191 -24
  138. package/src/component/garmin/auth.ts +0 -9
  139. package/src/component/garmin/private.ts +84 -12
  140. package/src/component/garmin/public.ts +812 -348
  141. package/src/component/garmin/utils.ts +17 -0
  142. package/src/component/schema.ts +5 -3
  143. package/src/{strava → component/strava}/auth.ts +143 -185
  144. package/src/component/strava/client.ts +20 -0
  145. package/src/component/strava/private.ts +147 -89
  146. package/src/component/strava/public.ts +191 -139
  147. package/src/{strava → component/strava/transform}/activity.ts +256 -276
  148. package/src/{strava → component/strava/transform}/athlete.ts +41 -47
  149. package/src/{strava/maps/sport-type.ts → component/strava/transform/maps/sportType.ts} +100 -99
  150. package/src/component/strava/types/specs/strava-api.json +4796 -0
  151. package/src/component/strava/types/stravaApi/client/client.gen.ts +290 -0
  152. package/src/component/strava/types/stravaApi/client/index.ts +25 -0
  153. package/src/component/strava/types/stravaApi/client/types.gen.ts +214 -0
  154. package/src/component/strava/types/stravaApi/client/utils.gen.ts +316 -0
  155. package/src/component/strava/types/stravaApi/client.gen.ts +16 -0
  156. package/src/component/strava/types/stravaApi/core/auth.gen.ts +41 -0
  157. package/src/component/strava/types/stravaApi/core/bodySerializer.gen.ts +82 -0
  158. package/src/component/strava/types/stravaApi/core/params.gen.ts +169 -0
  159. package/src/component/strava/types/stravaApi/core/pathSerializer.gen.ts +171 -0
  160. package/src/component/strava/types/stravaApi/core/queryKeySerializer.gen.ts +117 -0
  161. package/src/component/strava/types/stravaApi/core/serverSentEvents.gen.ts +243 -0
  162. package/src/component/strava/types/stravaApi/core/types.gen.ts +104 -0
  163. package/src/component/strava/types/stravaApi/core/utils.gen.ts +140 -0
  164. package/src/component/strava/types/stravaApi/index.ts +4 -0
  165. package/src/component/strava/types/stravaApi/sdk.gen.ts +410 -0
  166. package/src/component/strava/types/stravaApi/types.gen.ts +2435 -0
  167. package/src/component/strava/types/stravaApi/zod.gen.ts +1132 -0
  168. package/src/component/strava/utils.ts +52 -0
  169. package/src/component/utils.ts +11 -0
  170. package/dist/strava/activity.d.ts +0 -121
  171. package/dist/strava/activity.d.ts.map +0 -1
  172. package/dist/strava/activity.js.map +0 -1
  173. package/dist/strava/athlete.d.ts.map +0 -1
  174. package/dist/strava/athlete.js.map +0 -1
  175. package/dist/strava/auth.d.ts.map +0 -1
  176. package/dist/strava/auth.js.map +0 -1
  177. package/dist/strava/client.d.ts +0 -93
  178. package/dist/strava/client.d.ts.map +0 -1
  179. package/dist/strava/client.js +0 -158
  180. package/dist/strava/client.js.map +0 -1
  181. package/dist/strava/index.d.ts +0 -13
  182. package/dist/strava/index.d.ts.map +0 -1
  183. package/dist/strava/index.js +0 -17
  184. package/dist/strava/index.js.map +0 -1
  185. package/dist/strava/maps/sport-type.d.ts +0 -7
  186. package/dist/strava/maps/sport-type.d.ts.map +0 -1
  187. package/dist/strava/maps/sport-type.js.map +0 -1
  188. package/dist/strava/sync.d.ts +0 -104
  189. package/dist/strava/sync.d.ts.map +0 -1
  190. package/dist/strava/sync.js +0 -87
  191. package/dist/strava/sync.js.map +0 -1
  192. package/dist/strava/types.d.ts +0 -266
  193. package/dist/strava/types.d.ts.map +0 -1
  194. package/dist/strava/types.js +0 -8
  195. package/dist/strava/types.js.map +0 -1
  196. package/src/strava/activity.test.ts +0 -415
  197. package/src/strava/athlete.test.ts +0 -139
  198. package/src/strava/auth.test.ts +0 -78
  199. package/src/strava/client.ts +0 -212
  200. package/src/strava/index.ts +0 -54
  201. package/src/strava/maps/sport-type.test.ts +0 -69
  202. package/src/strava/sync.ts +0 -168
  203. package/src/strava/types.ts +0 -361
@@ -6,10 +6,10 @@
6
6
  import { v } from "convex/values";
7
7
  import { action } from "../_generated/server";
8
8
  import type { Doc, Id } from "../_generated/dataModel";
9
+ import { generateState } from "../utils.js";
9
10
  import {
10
11
  generateCodeVerifier,
11
12
  generateCodeChallenge,
12
- generateState,
13
13
  buildAuthUrl,
14
14
  exchangeCode,
15
15
  refreshToken,
@@ -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
- // ─── Public Actions ──────────────────────────────────────────────────────────
63
+ // ─── OAuth ──────────────────────────────────────────────────────────────────
64
64
 
65
65
  /**
66
66
  * Generate a Garmin OAuth 2.0 authorization URL with PKCE.
67
67
  *
68
- * If `userId` is provided, the PKCE code verifier and state are stored in the
69
- * component's `pendingOAuth` table so that `completeGarminOAuth` can look
70
- * them up automatically when the callback fires. This is the recommended
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.optional(v.string()),
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
- if (args.userId) {
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
- * Used by `registerRoutes` — the callback handler calls this with the
193
- * `code` and `state` from the redirect. The action looks up the pending
194
- * state (codeVerifier, userId) stored during `getGarminAuthUrl`,
195
- * exchanges for tokens, creates the connection, syncs data, and
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,
@@ -219,6 +131,13 @@ export const completeGarminOAuth = action({
219
131
  );
220
132
  }
221
133
 
134
+ if (!pending.codeVerifier) {
135
+ throw new Error(
136
+ "No code verifier found for this state parameter. " +
137
+ "The authorization may have expired or was already used.",
138
+ );
139
+ }
140
+
222
141
  const tokenResult = await exchangeCode({
223
142
  clientId: args.clientId,
224
143
  clientSecret: args.clientSecret,
@@ -255,140 +174,10 @@ export const completeGarminOAuth = action({
255
174
  });
256
175
  }
257
176
 
258
- const now = Math.floor(Date.now() / 1000);
259
- const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
260
- const timeRange = {
261
- uploadStartTimeInSeconds: thirtyDaysAgo,
262
- uploadEndTimeInSeconds: now,
263
- };
264
-
265
- const result = await ctx.runAction(api.garmin.public.syncAllTypes, {
266
- accessToken: tokenResult.access_token,
267
- connectionId,
268
- userId: pending.userId,
269
- uploadStartTimeInSeconds: timeRange.uploadStartTimeInSeconds,
270
- uploadEndTimeInSeconds: timeRange.uploadEndTimeInSeconds,
271
- });
272
-
273
- const _updated: null = await ctx.runMutation(api.public.updateConnection, {
274
- connectionId,
275
- lastDataUpdate: new Date().toISOString(),
276
- });
277
-
278
177
  return {
279
178
  connectionId,
280
179
  userId: pending.userId,
281
- synced: result.synced,
282
- errors: result.errors,
283
- };
284
- },
285
- });
286
-
287
- /**
288
- * Incremental Garmin sync for an already-connected user.
289
- *
290
- * Looks up the stored tokens, refreshes if expired, and syncs all data
291
- * types for the specified time range (defaults to last 30 days).
292
- */
293
- export const syncGarmin = action({
294
- args: {
295
- userId: v.string(),
296
- clientId: v.string(),
297
- clientSecret: v.string(),
298
- startTimeInSeconds: v.optional(v.number()),
299
- endTimeInSeconds: v.optional(v.number()),
300
- },
301
- handler: async (ctx, args): Promise<{
302
- synced: Record<string, number>;
303
- errors: Array<{ type: string; id: string; error: string }>;
304
- }> => {
305
- const connection: Doc<"connections"> | null = await ctx.runQuery(
306
- internal.private.getConnectionByProvider,
307
- { userId: args.userId, provider: "GARMIN" },
308
- );
309
- if (!connection) {
310
- throw new Error(
311
- `No Garmin connection found for user "${args.userId}". ` +
312
- "Call connectGarmin first.",
313
- );
314
- }
315
- if (!connection.active) {
316
- throw new Error(
317
- `Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
318
- );
319
- }
320
-
321
- const connectionId = connection._id;
322
-
323
- const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(internal.garmin.private.getTokens, {
324
- connectionId,
325
- });
326
- if (!tokenDoc) {
327
- throw new Error(
328
- "No Garmin tokens found for this connection. " +
329
- "The connection may have been created before token storage was available.",
330
- );
331
- }
332
-
333
- let accessToken = tokenDoc.accessToken;
334
-
335
- // Refresh the token if it's expired or about to expire
336
- const nowSeconds = Math.floor(Date.now() / 1000);
337
- if (
338
- tokenDoc.expiresAt &&
339
- tokenDoc.refreshToken &&
340
- nowSeconds >= tokenDoc.expiresAt - REFRESH_BUFFER_SECONDS
341
- ) {
342
- const refreshed = await refreshToken({
343
- clientId: args.clientId,
344
- clientSecret: args.clientSecret,
345
- refreshToken: tokenDoc.refreshToken,
346
- });
347
-
348
- accessToken = refreshed.access_token;
349
- const newExpiresAt = nowSeconds + refreshed.expires_in;
350
- const _refreshed: null = await ctx.runMutation(internal.garmin.private.storeTokens, {
351
- connectionId,
352
- accessToken: refreshed.access_token,
353
- refreshToken: refreshed.refresh_token,
354
- expiresAt: newExpiresAt,
355
- });
356
- }
357
-
358
- // Lazy backfill: resolve Garmin user ID if missing (for webhook mapping)
359
- if (!connection.providerUserId) {
360
- const wellnessClient = createWellnessClient(accessToken);
361
- const { data: userIdData } = await sdkUserId({ client: wellnessClient });
362
- const garminUserId = userIdData?.userId ?? null;
363
- if (garminUserId) {
364
- const _updated: null = await ctx.runMutation(api.public.updateConnection, {
365
- connectionId,
366
- providerUserId: garminUserId,
367
- });
368
- }
369
- }
370
-
371
- const now = Math.floor(Date.now() / 1000);
372
- const timeRange = {
373
- uploadStartTimeInSeconds:
374
- args.startTimeInSeconds ?? now - DEFAULT_SYNC_DAYS * 86400,
375
- uploadEndTimeInSeconds: args.endTimeInSeconds ?? now,
376
180
  };
377
-
378
- const result = await ctx.runAction(api.garmin.public.syncAllTypes, {
379
- accessToken,
380
- connectionId,
381
- userId: args.userId,
382
- uploadStartTimeInSeconds: timeRange.uploadStartTimeInSeconds,
383
- uploadEndTimeInSeconds: timeRange.uploadEndTimeInSeconds,
384
- });
385
-
386
- const _updated: null = await ctx.runMutation(api.public.updateConnection, {
387
- connectionId,
388
- lastDataUpdate: new Date().toISOString(),
389
- });
390
-
391
- return result;
392
181
  },
393
182
  });
394
183
 
@@ -439,158 +228,687 @@ export const disconnectGarmin = action({
439
228
  },
440
229
  });
441
230
 
442
- // ─── Training API ────────────────────────────────────────────────────────────
231
+ // ─── Pull ───────────────────────────────────────────────────────────────────
443
232
 
444
- /**
445
- * Push a planned workout from Soma's DB to Garmin Connect.
446
- *
447
- * Reads the planned workout document, transforms it to Garmin Training API V2
448
- * format, creates the workout at Garmin, and optionally schedules it if a
449
- * `planned_date` is set in the metadata.
450
- *
451
- * Returns the Garmin workout ID and schedule ID (if scheduled).
452
- */
453
- export const pushPlannedWorkout = action({
233
+ export const pullActivities = action({
454
234
  args: {
455
235
  userId: v.string(),
456
236
  clientId: v.string(),
457
237
  clientSecret: v.string(),
458
- plannedWorkoutId: v.string(),
459
- workoutProvider: v.optional(v.string()),
238
+ startTimeInSeconds: v.optional(v.number()),
239
+ endTimeInSeconds: v.optional(v.number()),
460
240
  },
461
- handler: async (ctx, args): Promise<{ garminWorkoutId: number; garminScheduleId: number | null }> => {
462
- const connection: Doc<"connections"> | null = await ctx.runQuery(
463
- internal.private.getConnectionByProvider,
464
- { userId: args.userId, provider: "GARMIN" },
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
+ },
465
250
  );
466
- if (!connection) {
467
- throw new Error(
468
- `No Garmin connection found for user "${args.userId}". ` +
469
- "Call connectGarmin first.",
470
- );
471
- }
472
- if (!connection.active) {
473
- throw new Error(
474
- `Garmin connection for user "${args.userId}" is inactive. Reconnect first.`,
475
- );
476
- }
477
251
 
478
- const connectionId = connection._id;
252
+ const timeRangeQuery = buildTimeRangeQuery(args, accessToken);
479
253
 
480
- const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(internal.garmin.private.getTokens, {
481
- connectionId,
482
- });
483
- if (!tokenDoc) {
484
- throw new Error(
485
- "No Garmin tokens found for this connection. " +
486
- "The connection may have been created before token storage was available.",
487
- );
488
- }
254
+ const wellnessClient = createWellnessClient(accessToken);
255
+ const synced = { activities: 0 };
256
+ const errors: Array<{ type: "activity"; id: string; error: string }> = [];
489
257
 
490
- // Always force-refresh the token for Training API calls to rule out
491
- // stale tokens (the initial sync swallows 401 errors silently).
492
- let accessToken = tokenDoc.accessToken;
258
+ try {
259
+ const { data: activities, error } = await getActivities({
260
+ client: wellnessClient,
261
+ query: timeRangeQuery,
262
+ });
493
263
 
494
- if (tokenDoc.refreshToken) {
495
- try {
496
- const refreshed = await refreshToken({
497
- clientId: args.clientId,
498
- clientSecret: args.clientSecret,
499
- refreshToken: tokenDoc.refreshToken,
500
- });
264
+ if (error || !activities) {
265
+ throw new Error(error ? JSON.stringify(error) : "No data");
266
+ }
501
267
 
502
- accessToken = refreshed.access_token;
503
- const nowSeconds = Math.floor(Date.now() / 1000);
504
- const newExpiresAt = nowSeconds + refreshed.expires_in;
505
- const _refreshed: null = await ctx.runMutation(internal.garmin.private.storeTokens, {
506
- connectionId,
507
- accessToken: refreshed.access_token,
508
- refreshToken: refreshed.refresh_token,
509
- expiresAt: newExpiresAt,
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),
510
793
  });
511
- } catch (refreshErr) {
512
- throw new Error(
513
- `Garmin token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}. ` +
514
- "The user may need to reconnect their Garmin account.",
515
- );
516
794
  }
517
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
+ */
518
805
 
519
- const plannedWorkout: Doc<"plannedWorkouts"> | null = await ctx.runQuery(
520
- api.public.getPlannedWorkout,
521
- { plannedWorkoutId: args.plannedWorkoutId as never },
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" },
522
821
  );
523
- if (!plannedWorkout) {
822
+ if (!connection) {
524
823
  throw new Error(
525
- `Planned workout "${args.plannedWorkoutId}" not found.`,
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.`,
526
831
  );
527
832
  }
528
833
 
529
- const providerName = args.workoutProvider ?? "Soma";
530
- const garminWorkout = transformPlannedWorkoutToGarmin(
531
- plannedWorkout,
532
- providerName,
533
- );
534
-
535
- const trainingClient = createTrainingClient(accessToken);
834
+ const connectionId = connection._id;
536
835
 
537
- const { data: created, error: createError } = await sdkCreateWorkoutV2({
538
- client: trainingClient,
539
- body: garminWorkout,
836
+ const tokenDoc: Doc<"providerTokens"> | null = await ctx.runQuery(internal.garmin.private.getTokens, {
837
+ connectionId,
540
838
  });
541
- if (createError || !created) {
839
+ if (!tokenDoc) {
542
840
  throw new Error(
543
- `Garmin API error creating workout: ${createError ? JSON.stringify(createError) : "No data"}`,
841
+ "No Garmin tokens found for this connection. " +
842
+ "The connection may have been created before token storage was available.",
544
843
  );
545
844
  }
546
845
 
547
- if (!created.workoutId) {
548
- throw new Error("Garmin API did not return a workoutId after creation.");
549
- }
846
+ let accessToken = tokenDoc.accessToken;
550
847
 
551
- let garminScheduleId: number | null = null;
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
+ });
552
860
 
553
- const plannedDate = plannedWorkout.metadata?.planned_date;
554
- if (plannedDate) {
555
- const { data: scheduleId, error: scheduleError } = await sdkCreateWorkoutSchedule({
556
- client: trainingClient,
557
- body: { workoutId: Number(created.workoutId), date: plannedDate },
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,
558
868
  });
559
- if (scheduleError) {
560
- throw new Error(
561
- `Garmin API error creating schedule: ${JSON.stringify(scheduleError)}`,
562
- );
563
- }
564
- garminScheduleId = scheduleId ?? null;
565
869
  }
566
870
 
567
- // Store the Garmin workout/schedule IDs back on the planned workout
568
- // so the host app can match completed activities to planned sessions.
569
- const _ingested: Id<"plannedWorkouts"> = await ctx.runMutation(api.public.ingestPlannedWorkout, {
570
- ...plannedWorkout,
571
- _id: undefined,
572
- _creationTime: undefined,
573
- metadata: {
574
- ...plannedWorkout.metadata,
575
- provider_workout_id: String(created.workoutId),
576
- provider_schedule_id:
577
- garminScheduleId != null ? String(garminScheduleId) : undefined,
578
- },
579
- } 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
+ }
580
883
 
581
- return {
582
- garminWorkoutId: created.workoutId,
583
- garminScheduleId,
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,
584
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;
585
905
  },
586
906
  });
587
907
 
588
- // ─── Sync Engine ────────────────────────────────────────────────────────────
589
-
590
908
  /**
591
909
  * Fetch and ingest all Garmin wellness data types for a time range.
592
910
  *
593
- * Called by the public actions (connectGarmin, completeGarminOAuth, syncGarmin)
911
+ * Called by syncGarmin after obtaining a valid access token.
594
912
  * after obtaining a valid access token.
595
913
  */
596
914
  export const syncAllTypes = action({
@@ -940,3 +1258,149 @@ export const syncAllTypes = action({
940
1258
  },
941
1259
  });
942
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
+