@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
@@ -4,9 +4,10 @@
4
4
  // credentials automatically from env vars or constructor config.
5
5
  import { v } from "convex/values";
6
6
  import { action } from "../_generated/server";
7
- import { generateCodeVerifier, generateCodeChallenge, generateState, buildAuthUrl, exchangeCode, refreshToken, } from "./auth.js";
7
+ import { generateState } from "../utils.js";
8
+ import { generateCodeVerifier, generateCodeChallenge, buildAuthUrl, exchangeCode, refreshToken, } from "./auth.js";
8
9
  import { createWellnessClient, createTrainingClient, } from "./client.js";
9
- import { timeRangeQuery } from "./utils.js";
10
+ import { buildTimeRangeQuery, timeRangeQuery } from "./utils.js";
10
11
  import { createWorkoutV2 as sdkCreateWorkoutV2, createWorkoutSchedule as sdkCreateWorkoutSchedule, } from "./types/trainingApiWorkouts/sdk.gen";
11
12
  import { userId as sdkUserId, dereg as sdkDereg, getActivities, getDailies, getSleeps, getBodyComps, getMct, getBloodPressures, getSkinTemp, getUserMetrics, getHrv, getStressDetails, getPulseox, getRespiration, } from "./types/wellnessApi/sdk.gen";
12
13
  import { transformActivity } from "./transform/activity.js";
@@ -27,23 +28,19 @@ import { api, internal } from "../_generated/api";
27
28
  const DEFAULT_SYNC_DAYS = 30;
28
29
  // Refresh buffer: refresh tokens 10 minutes before expiry
29
30
  const REFRESH_BUFFER_SECONDS = 600;
30
- // ─── Public Actions ──────────────────────────────────────────────────────────
31
+ // ─── OAuth ──────────────────────────────────────────────────────────────────
31
32
  /**
32
33
  * Generate a Garmin OAuth 2.0 authorization URL with PKCE.
33
34
  *
34
- * If `userId` is provided, the PKCE code verifier and state are stored in the
35
- * component's `pendingOAuth` table so that `completeGarminOAuth` can look
36
- * them up automatically when the callback fires. This is the recommended
37
- * flow when using `registerRoutes`.
38
- *
39
- * If `userId` is omitted, the host app must store the returned `codeVerifier`
40
- * itself and pass it to `connectGarmin` manually.
35
+ * The PKCE code verifier and state are stored in the component's
36
+ * `pendingOAuth` table so that `completeGarminOAuth` can look them up
37
+ * automatically when the callback fires via `registerRoutes`.
41
38
  */
42
39
  export const getGarminAuthUrl = action({
43
40
  args: {
44
41
  clientId: v.string(),
45
42
  redirectUri: v.optional(v.string()),
46
- userId: v.optional(v.string()),
43
+ userId: v.string(),
47
44
  },
48
45
  handler: async (ctx, args) => {
49
46
  const codeVerifier = generateCodeVerifier();
@@ -55,94 +52,25 @@ export const getGarminAuthUrl = action({
55
52
  redirectUri: args.redirectUri,
56
53
  state,
57
54
  });
58
- if (args.userId) {
59
- await ctx.runMutation(internal.garmin.private.storePendingOAuth, {
60
- provider: "GARMIN",
61
- state,
62
- codeVerifier,
63
- userId: args.userId,
64
- });
65
- }
66
- return { authUrl, state, codeVerifier };
67
- },
68
- });
69
- /**
70
- * Exchange an authorization code for tokens + initial sync.
71
- *
72
- * Used in the manual flow where the host app stores the code verifier
73
- * and handles the callback itself.
74
- */
75
- export const connectGarmin = action({
76
- args: {
77
- userId: v.string(),
78
- clientId: v.string(),
79
- clientSecret: v.string(),
80
- code: v.string(),
81
- codeVerifier: v.string(),
82
- redirectUri: v.optional(v.string()),
83
- },
84
- handler: async (ctx, args) => {
85
- const tokenResult = await exchangeCode({
86
- clientId: args.clientId,
87
- clientSecret: args.clientSecret,
88
- code: args.code,
89
- codeVerifier: args.codeVerifier,
90
- redirectUri: args.redirectUri,
91
- });
92
- const connectionId = await ctx.runMutation(api.public.connect, {
93
- userId: args.userId,
55
+ await ctx.runMutation(internal.garmin.private.storePendingOAuth, {
94
56
  provider: "GARMIN",
95
- });
96
- const expiresAt = Math.floor(Date.now() / 1000) + tokenResult.expires_in;
97
- const _stored = await ctx.runMutation(internal.garmin.private.storeTokens, {
98
- connectionId,
99
- accessToken: tokenResult.access_token,
100
- refreshToken: tokenResult.refresh_token,
101
- expiresAt,
102
- });
103
- // Best-effort: resolve Garmin user ID for webhook mapping
104
- const wellnessClient = createWellnessClient(tokenResult.access_token);
105
- const { data: userIdData } = await sdkUserId({ client: wellnessClient });
106
- const garminUserId = userIdData?.userId ?? null;
107
- if (garminUserId) {
108
- const _updated = await ctx.runMutation(api.public.updateConnection, {
109
- connectionId,
110
- providerUserId: garminUserId,
111
- });
112
- }
113
- const now = Math.floor(Date.now() / 1000);
114
- const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
115
- const timeRange = {
116
- uploadStartTimeInSeconds: thirtyDaysAgo,
117
- uploadEndTimeInSeconds: now,
118
- };
119
- const result = await ctx.runAction(api.garmin.public.syncAllTypes, {
120
- accessToken: tokenResult.access_token,
121
- connectionId,
57
+ state,
58
+ codeVerifier,
122
59
  userId: args.userId,
123
- uploadStartTimeInSeconds: timeRange.uploadStartTimeInSeconds,
124
- uploadEndTimeInSeconds: timeRange.uploadEndTimeInSeconds,
125
60
  });
126
- const _updated = await ctx.runMutation(api.public.updateConnection, {
127
- connectionId,
128
- lastDataUpdate: new Date().toISOString(),
129
- });
130
- return {
131
- connectionId,
132
- userId: args.userId,
133
- synced: result.synced,
134
- errors: result.errors,
135
- };
61
+ return { authUrl, state, codeVerifier };
136
62
  },
137
63
  });
138
64
  /**
139
65
  * Complete a Garmin OAuth 2.0 flow using stored pending state.
140
66
  *
141
- * Used by `registerRoutes` — the callback handler calls this with the
142
- * `code` and `state` from the redirect. The action looks up the pending
143
- * state (codeVerifier, userId) stored during `getGarminAuthUrl`,
144
- * exchanges for tokens, creates the connection, syncs data, and
145
- * cleans up the pending entry.
67
+ * Called internally by `registerRoutes` — the callback handler calls
68
+ * this with the `code` and `state` from the redirect. The action looks
69
+ * up the pending state (codeVerifier, userId) stored during
70
+ * `getGarminAuthUrl`, exchanges for tokens, creates the connection,
71
+ * stores tokens, and cleans up the pending entry.
72
+ *
73
+ * The host app is responsible for calling `syncGarmin` afterwards.
146
74
  */
147
75
  export const completeGarminOAuth = action({
148
76
  args: {
@@ -160,6 +88,10 @@ export const completeGarminOAuth = action({
160
88
  throw new Error("No pending Garmin OAuth state found for this state parameter. " +
161
89
  "The authorization may have expired or was already used.");
162
90
  }
91
+ if (!pending.codeVerifier) {
92
+ throw new Error("No code verifier found for this state parameter. " +
93
+ "The authorization may have expired or was already used.");
94
+ }
163
95
  const tokenResult = await exchangeCode({
164
96
  clientId: args.clientId,
165
97
  clientSecret: args.clientSecret,
@@ -191,29 +123,570 @@ export const completeGarminOAuth = action({
191
123
  providerUserId: garminUserId,
192
124
  });
193
125
  }
194
- const now = Math.floor(Date.now() / 1000);
195
- const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
196
- const timeRange = {
197
- uploadStartTimeInSeconds: thirtyDaysAgo,
198
- uploadEndTimeInSeconds: now,
199
- };
200
- const result = await ctx.runAction(api.garmin.public.syncAllTypes, {
201
- accessToken: tokenResult.access_token,
126
+ return {
202
127
  connectionId,
203
128
  userId: pending.userId,
204
- uploadStartTimeInSeconds: timeRange.uploadStartTimeInSeconds,
205
- uploadEndTimeInSeconds: timeRange.uploadEndTimeInSeconds,
129
+ };
130
+ },
131
+ });
132
+ /**
133
+ * Disconnect a user from Garmin.
134
+ *
135
+ * Deregisters the user via the Garmin API (best-effort), deletes stored
136
+ * tokens, and sets the connection to inactive.
137
+ */
138
+ export const disconnectGarmin = action({
139
+ args: {
140
+ userId: v.string(),
141
+ },
142
+ handler: async (ctx, args) => {
143
+ const connection = await ctx.runQuery(internal.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
144
+ if (!connection) {
145
+ throw new Error(`No Garmin connection found for user "${args.userId}".`);
146
+ }
147
+ const connectionId = connection._id;
148
+ // Best-effort: deregister user at Garmin
149
+ const tokenDoc = await ctx.runQuery(internal.garmin.private.getTokens, {
150
+ connectionId,
206
151
  });
207
- const _updated = await ctx.runMutation(api.public.updateConnection, {
152
+ if (tokenDoc) {
153
+ try {
154
+ const wellnessClient = createWellnessClient(tokenDoc.accessToken);
155
+ await sdkDereg({ client: wellnessClient });
156
+ }
157
+ catch {
158
+ // Deregistration is best-effort; proceed with local cleanup
159
+ }
160
+ }
161
+ const _deleted = await ctx.runMutation(internal.garmin.private.deleteTokens, { connectionId });
162
+ const _disconnected = await ctx.runMutation(api.public.disconnect, {
163
+ userId: args.userId,
164
+ provider: "GARMIN",
165
+ });
166
+ return null;
167
+ },
168
+ });
169
+ // ─── Pull ───────────────────────────────────────────────────────────────────
170
+ export const pullActivities = action({
171
+ args: {
172
+ userId: v.string(),
173
+ clientId: v.string(),
174
+ clientSecret: v.string(),
175
+ startTimeInSeconds: v.optional(v.number()),
176
+ endTimeInSeconds: v.optional(v.number()),
177
+ },
178
+ handler: async (ctx, args) => {
179
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, {
180
+ userId: args.userId,
181
+ clientId: args.clientId,
182
+ clientSecret: args.clientSecret,
183
+ });
184
+ const timeRangeQuery = buildTimeRangeQuery(args, accessToken);
185
+ const wellnessClient = createWellnessClient(accessToken);
186
+ const synced = { activities: 0 };
187
+ const errors = [];
188
+ try {
189
+ const { data: activities, error } = await getActivities({
190
+ client: wellnessClient,
191
+ query: timeRangeQuery,
192
+ });
193
+ if (error || !activities) {
194
+ throw new Error(error ? JSON.stringify(error) : "No data");
195
+ }
196
+ for (const activity of activities) {
197
+ try {
198
+ const data = transformActivity(activity);
199
+ await ctx.runMutation(api.public.ingestActivity, {
200
+ connectionId,
201
+ userId: args.userId,
202
+ ...data,
203
+ });
204
+ synced.activities++;
205
+ }
206
+ catch (err) {
207
+ errors.push({
208
+ type: "activity",
209
+ id: activity.summaryId ?? String(activity.activityId),
210
+ error: err instanceof Error ? err.message : String(err),
211
+ });
212
+ }
213
+ }
214
+ }
215
+ catch (err) {
216
+ errors.push({
217
+ type: "activity",
218
+ id: "fetch",
219
+ error: err instanceof Error ? err.message : String(err),
220
+ });
221
+ }
222
+ await ctx.runMutation(api.public.updateConnection, {
208
223
  connectionId,
209
224
  lastDataUpdate: new Date().toISOString(),
210
225
  });
211
- return {
212
- connectionId,
213
- userId: pending.userId,
214
- synced: result.synced,
215
- errors: result.errors,
226
+ return { synced, errors };
227
+ },
228
+ });
229
+ export const pullDailies = action({
230
+ args: {
231
+ userId: v.string(),
232
+ clientId: v.string(),
233
+ clientSecret: v.string(),
234
+ startTimeInSeconds: v.optional(v.number()),
235
+ endTimeInSeconds: v.optional(v.number()),
236
+ },
237
+ handler: async (ctx, args) => {
238
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
239
+ const query = buildTimeRangeQuery(args, accessToken);
240
+ const wellnessClient = createWellnessClient(accessToken);
241
+ const synced = { dailies: 0 };
242
+ const errors = [];
243
+ try {
244
+ const { data: dailies, error } = await getDailies({ client: wellnessClient, query });
245
+ if (error || !dailies)
246
+ throw new Error(error ? JSON.stringify(error) : "No data");
247
+ for (const daily of dailies) {
248
+ try {
249
+ const data = transformDailies(daily);
250
+ if (!data)
251
+ continue;
252
+ await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
253
+ synced.dailies++;
254
+ }
255
+ catch (err) {
256
+ errors.push({ type: "daily", id: daily.summaryId ?? daily.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
257
+ }
258
+ }
259
+ }
260
+ catch (err) {
261
+ errors.push({ type: "daily", id: "fetch", error: err instanceof Error ? err.message : String(err) });
262
+ }
263
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
264
+ return { synced, errors };
265
+ },
266
+ });
267
+ export const pullSleep = action({
268
+ args: {
269
+ userId: v.string(),
270
+ clientId: v.string(),
271
+ clientSecret: v.string(),
272
+ startTimeInSeconds: v.optional(v.number()),
273
+ endTimeInSeconds: v.optional(v.number()),
274
+ },
275
+ handler: async (ctx, args) => {
276
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
277
+ const query = buildTimeRangeQuery(args, accessToken);
278
+ const wellnessClient = createWellnessClient(accessToken);
279
+ const synced = { sleep: 0 };
280
+ const errors = [];
281
+ try {
282
+ const { data: sleeps, error } = await getSleeps({ client: wellnessClient, query });
283
+ if (error || !sleeps)
284
+ throw new Error(error ? JSON.stringify(error) : "No data");
285
+ for (const sleep of sleeps) {
286
+ try {
287
+ const data = transformSleeps(sleep);
288
+ await ctx.runMutation(api.public.ingestSleep, { connectionId, userId: args.userId, ...data });
289
+ synced.sleep++;
290
+ }
291
+ catch (err) {
292
+ errors.push({ type: "sleep", id: sleep.summaryId ?? sleep.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
293
+ }
294
+ }
295
+ }
296
+ catch (err) {
297
+ errors.push({ type: "sleep", id: "fetch", error: err instanceof Error ? err.message : String(err) });
298
+ }
299
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
300
+ return { synced, errors };
301
+ },
302
+ });
303
+ export const pullBody = action({
304
+ args: {
305
+ userId: v.string(),
306
+ clientId: v.string(),
307
+ clientSecret: v.string(),
308
+ startTimeInSeconds: v.optional(v.number()),
309
+ endTimeInSeconds: v.optional(v.number()),
310
+ },
311
+ handler: async (ctx, args) => {
312
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
313
+ const query = buildTimeRangeQuery(args, accessToken);
314
+ const wellnessClient = createWellnessClient(accessToken);
315
+ const synced = { body: 0 };
316
+ const errors = [];
317
+ try {
318
+ const { data: bodyComps, error } = await getBodyComps({ client: wellnessClient, query });
319
+ if (error || !bodyComps)
320
+ throw new Error(error ? JSON.stringify(error) : "No data");
321
+ for (const body of bodyComps) {
322
+ try {
323
+ const data = transformBodyComposition(body);
324
+ if (!data)
325
+ continue;
326
+ await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
327
+ synced.body++;
328
+ }
329
+ catch (err) {
330
+ errors.push({ type: "body", id: body.summaryId ?? String(body.measurementTimeInSeconds), error: err instanceof Error ? err.message : String(err) });
331
+ }
332
+ }
333
+ }
334
+ catch (err) {
335
+ errors.push({ type: "body", id: "fetch", error: err instanceof Error ? err.message : String(err) });
336
+ }
337
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
338
+ return { synced, errors };
339
+ },
340
+ });
341
+ export const pullMenstruation = action({
342
+ args: {
343
+ userId: v.string(),
344
+ clientId: v.string(),
345
+ clientSecret: v.string(),
346
+ startTimeInSeconds: v.optional(v.number()),
347
+ endTimeInSeconds: v.optional(v.number()),
348
+ },
349
+ handler: async (ctx, args) => {
350
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
351
+ const query = buildTimeRangeQuery(args, accessToken);
352
+ const wellnessClient = createWellnessClient(accessToken);
353
+ const synced = { menstruation: 0 };
354
+ const errors = [];
355
+ try {
356
+ const { data: records, error } = await getMct({ client: wellnessClient, query });
357
+ if (error || !records)
358
+ throw new Error(error ? JSON.stringify(error) : "No data");
359
+ for (const record of records) {
360
+ try {
361
+ const data = transformMenstrualCycleTracking(record);
362
+ await ctx.runMutation(api.public.ingestMenstruation, { connectionId, userId: args.userId, ...data });
363
+ synced.menstruation++;
364
+ }
365
+ catch (err) {
366
+ errors.push({ type: "menstruation", id: record.summaryId ?? record.periodStartDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
367
+ }
368
+ }
369
+ }
370
+ catch (err) {
371
+ errors.push({ type: "menstruation", id: "fetch", error: err instanceof Error ? err.message : String(err) });
372
+ }
373
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
374
+ return { synced, errors };
375
+ },
376
+ });
377
+ export const pullBloodPressures = action({
378
+ args: {
379
+ userId: v.string(),
380
+ clientId: v.string(),
381
+ clientSecret: v.string(),
382
+ startTimeInSeconds: v.optional(v.number()),
383
+ endTimeInSeconds: v.optional(v.number()),
384
+ },
385
+ handler: async (ctx, args) => {
386
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
387
+ const query = buildTimeRangeQuery(args, accessToken);
388
+ const wellnessClient = createWellnessClient(accessToken);
389
+ const synced = { bloodPressures: 0 };
390
+ const errors = [];
391
+ try {
392
+ const { data: bpRecords, error } = await getBloodPressures({ client: wellnessClient, query });
393
+ if (error || !bpRecords)
394
+ throw new Error(error ? JSON.stringify(error) : "No data");
395
+ for (const bp of bpRecords) {
396
+ try {
397
+ const data = transformBloodPressure(bp);
398
+ if (!data)
399
+ continue;
400
+ await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
401
+ synced.bloodPressures++;
402
+ }
403
+ catch (err) {
404
+ errors.push({ type: "bloodPressure", id: bp.summaryId ?? String(bp.measurementTimeInSeconds), error: err instanceof Error ? err.message : String(err) });
405
+ }
406
+ }
407
+ }
408
+ catch (err) {
409
+ errors.push({ type: "bloodPressure", id: "fetch", error: err instanceof Error ? err.message : String(err) });
410
+ }
411
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
412
+ return { synced, errors };
413
+ },
414
+ });
415
+ export const pullSkinTemperature = action({
416
+ args: {
417
+ userId: v.string(),
418
+ clientId: v.string(),
419
+ clientSecret: v.string(),
420
+ startTimeInSeconds: v.optional(v.number()),
421
+ endTimeInSeconds: v.optional(v.number()),
422
+ },
423
+ handler: async (ctx, args) => {
424
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
425
+ const query = buildTimeRangeQuery(args, accessToken);
426
+ const wellnessClient = createWellnessClient(accessToken);
427
+ const synced = { skinTemp: 0 };
428
+ const errors = [];
429
+ try {
430
+ const { data: skinRecords, error } = await getSkinTemp({ client: wellnessClient, query });
431
+ if (error || !skinRecords)
432
+ throw new Error(error ? JSON.stringify(error) : "No data");
433
+ for (const skin of skinRecords) {
434
+ try {
435
+ const data = transformSkinTemperature(skin);
436
+ if (!data)
437
+ continue;
438
+ await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
439
+ synced.skinTemp++;
440
+ }
441
+ catch (err) {
442
+ errors.push({ type: "skinTemp", id: skin.summaryId ?? skin.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
443
+ }
444
+ }
445
+ }
446
+ catch (err) {
447
+ errors.push({ type: "skinTemp", id: "fetch", error: err instanceof Error ? err.message : String(err) });
448
+ }
449
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
450
+ return { synced, errors };
451
+ },
452
+ });
453
+ export const pullUserMetrics = action({
454
+ args: {
455
+ userId: v.string(),
456
+ clientId: v.string(),
457
+ clientSecret: v.string(),
458
+ startTimeInSeconds: v.optional(v.number()),
459
+ endTimeInSeconds: v.optional(v.number()),
460
+ },
461
+ handler: async (ctx, args) => {
462
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
463
+ const query = buildTimeRangeQuery(args, accessToken);
464
+ const wellnessClient = createWellnessClient(accessToken);
465
+ const synced = { userMetrics: 0 };
466
+ const errors = [];
467
+ try {
468
+ const { data: metricsRecords, error } = await getUserMetrics({ client: wellnessClient, query });
469
+ if (error || !metricsRecords)
470
+ throw new Error(error ? JSON.stringify(error) : "No data");
471
+ for (const metrics of metricsRecords) {
472
+ try {
473
+ const data = transformUserMetrics(metrics);
474
+ if (!data)
475
+ continue;
476
+ await ctx.runMutation(api.public.ingestBody, { connectionId, userId: args.userId, ...data });
477
+ synced.userMetrics++;
478
+ }
479
+ catch (err) {
480
+ errors.push({ type: "userMetrics", id: metrics.summaryId ?? metrics.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
481
+ }
482
+ }
483
+ }
484
+ catch (err) {
485
+ errors.push({ type: "userMetrics", id: "fetch", error: err instanceof Error ? err.message : String(err) });
486
+ }
487
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
488
+ return { synced, errors };
489
+ },
490
+ });
491
+ export const pullHRV = action({
492
+ args: {
493
+ userId: v.string(),
494
+ clientId: v.string(),
495
+ clientSecret: v.string(),
496
+ startTimeInSeconds: v.optional(v.number()),
497
+ endTimeInSeconds: v.optional(v.number()),
498
+ },
499
+ handler: async (ctx, args) => {
500
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
501
+ const query = buildTimeRangeQuery(args, accessToken);
502
+ const wellnessClient = createWellnessClient(accessToken);
503
+ const synced = { hrv: 0 };
504
+ const errors = [];
505
+ try {
506
+ const { data: hrvRecords, error } = await getHrv({ client: wellnessClient, query });
507
+ if (error || !hrvRecords)
508
+ throw new Error(error ? JSON.stringify(error) : "No data");
509
+ for (const hrv of hrvRecords) {
510
+ try {
511
+ const data = transformHRVSummary(hrv);
512
+ if (!data)
513
+ continue;
514
+ await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
515
+ synced.hrv++;
516
+ }
517
+ catch (err) {
518
+ errors.push({ type: "hrv", id: hrv.summaryId ?? hrv.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
519
+ }
520
+ }
521
+ }
522
+ catch (err) {
523
+ errors.push({ type: "hrv", id: "fetch", error: err instanceof Error ? err.message : String(err) });
524
+ }
525
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
526
+ return { synced, errors };
527
+ },
528
+ });
529
+ export const pullStressDetails = action({
530
+ args: {
531
+ userId: v.string(),
532
+ clientId: v.string(),
533
+ clientSecret: v.string(),
534
+ startTimeInSeconds: v.optional(v.number()),
535
+ endTimeInSeconds: v.optional(v.number()),
536
+ },
537
+ handler: async (ctx, args) => {
538
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
539
+ const query = buildTimeRangeQuery(args, accessToken);
540
+ const wellnessClient = createWellnessClient(accessToken);
541
+ const synced = { stressDetails: 0 };
542
+ const errors = [];
543
+ try {
544
+ const { data: stressRecords, error } = await getStressDetails({ client: wellnessClient, query });
545
+ if (error || !stressRecords)
546
+ throw new Error(error ? JSON.stringify(error) : "No data");
547
+ for (const stress of stressRecords) {
548
+ try {
549
+ const data = transformStress(stress);
550
+ if (!data)
551
+ continue;
552
+ await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
553
+ synced.stressDetails++;
554
+ }
555
+ catch (err) {
556
+ errors.push({ type: "stressDetails", id: stress.summaryId ?? stress.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
557
+ }
558
+ }
559
+ }
560
+ catch (err) {
561
+ errors.push({ type: "stressDetails", id: "fetch", error: err instanceof Error ? err.message : String(err) });
562
+ }
563
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
564
+ return { synced, errors };
565
+ },
566
+ });
567
+ export const pullPulseOx = action({
568
+ args: {
569
+ userId: v.string(),
570
+ clientId: v.string(),
571
+ clientSecret: v.string(),
572
+ startTimeInSeconds: v.optional(v.number()),
573
+ endTimeInSeconds: v.optional(v.number()),
574
+ },
575
+ handler: async (ctx, args) => {
576
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
577
+ const query = buildTimeRangeQuery(args, accessToken);
578
+ const wellnessClient = createWellnessClient(accessToken);
579
+ const synced = { pulseOx: 0 };
580
+ const errors = [];
581
+ try {
582
+ const { data: pulseOxRecords, error } = await getPulseox({ client: wellnessClient, query });
583
+ if (error || !pulseOxRecords)
584
+ throw new Error(error ? JSON.stringify(error) : "No data");
585
+ for (const po of pulseOxRecords) {
586
+ try {
587
+ const data = transformPulseOx(po);
588
+ if (!data)
589
+ continue;
590
+ await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
591
+ synced.pulseOx++;
592
+ }
593
+ catch (err) {
594
+ errors.push({ type: "pulseOx", id: po.summaryId ?? po.calendarDate ?? "unknown", error: err instanceof Error ? err.message : String(err) });
595
+ }
596
+ }
597
+ }
598
+ catch (err) {
599
+ errors.push({ type: "pulseOx", id: "fetch", error: err instanceof Error ? err.message : String(err) });
600
+ }
601
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
602
+ return { synced, errors };
603
+ },
604
+ });
605
+ export const pullRespiration = action({
606
+ args: {
607
+ userId: v.string(),
608
+ clientId: v.string(),
609
+ clientSecret: v.string(),
610
+ startTimeInSeconds: v.optional(v.number()),
611
+ endTimeInSeconds: v.optional(v.number()),
612
+ },
613
+ handler: async (ctx, args) => {
614
+ const { connectionId, accessToken } = await ctx.runAction(internal.garmin.private.resolveConnectionAndAccessToken, { userId: args.userId, clientId: args.clientId, clientSecret: args.clientSecret });
615
+ const query = buildTimeRangeQuery(args, accessToken);
616
+ const wellnessClient = createWellnessClient(accessToken);
617
+ const synced = { respiration: 0 };
618
+ const errors = [];
619
+ try {
620
+ const { data: respRecords, error } = await getRespiration({ client: wellnessClient, query });
621
+ if (error || !respRecords)
622
+ throw new Error(error ? JSON.stringify(error) : "No data");
623
+ for (const resp of respRecords) {
624
+ try {
625
+ const data = transformRespiration(resp);
626
+ if (!data)
627
+ continue;
628
+ await ctx.runMutation(api.public.ingestDaily, { connectionId, userId: args.userId, ...data });
629
+ synced.respiration++;
630
+ }
631
+ catch (err) {
632
+ errors.push({ type: "respiration", id: resp.summaryId ?? "unknown", error: err instanceof Error ? err.message : String(err) });
633
+ }
634
+ }
635
+ }
636
+ catch (err) {
637
+ errors.push({ type: "respiration", id: "fetch", error: err instanceof Error ? err.message : String(err) });
638
+ }
639
+ await ctx.runMutation(api.public.updateConnection, { connectionId, lastDataUpdate: new Date().toISOString() });
640
+ return { synced, errors };
641
+ },
642
+ });
643
+ export const pullAll = action({
644
+ args: {
645
+ userId: v.string(),
646
+ clientId: v.string(),
647
+ clientSecret: v.string(),
648
+ startTimeInSeconds: v.optional(v.number()),
649
+ endTimeInSeconds: v.optional(v.number()),
650
+ },
651
+ handler: async (ctx, args) => {
652
+ const sharedArgs = {
653
+ userId: args.userId,
654
+ clientId: args.clientId,
655
+ clientSecret: args.clientSecret,
656
+ startTimeInSeconds: args.startTimeInSeconds,
657
+ endTimeInSeconds: args.endTimeInSeconds,
216
658
  };
659
+ const pullFns = [
660
+ { ref: api.garmin.public.pullActivities, name: "activities" },
661
+ { ref: api.garmin.public.pullDailies, name: "dailies" },
662
+ { ref: api.garmin.public.pullSleep, name: "sleep" },
663
+ { ref: api.garmin.public.pullBody, name: "body" },
664
+ { ref: api.garmin.public.pullMenstruation, name: "menstruation" },
665
+ { ref: api.garmin.public.pullBloodPressures, name: "bloodPressures" },
666
+ { ref: api.garmin.public.pullSkinTemperature, name: "skinTemp" },
667
+ { ref: api.garmin.public.pullUserMetrics, name: "userMetrics" },
668
+ { ref: api.garmin.public.pullHRV, name: "hrv" },
669
+ { ref: api.garmin.public.pullStressDetails, name: "stressDetails" },
670
+ { ref: api.garmin.public.pullPulseOx, name: "pulseOx" },
671
+ { ref: api.garmin.public.pullRespiration, name: "respiration" },
672
+ ];
673
+ const synced = {};
674
+ const errors = [];
675
+ for (const { ref, name } of pullFns) {
676
+ try {
677
+ const result = await ctx.runAction(ref, sharedArgs);
678
+ Object.assign(synced, result.synced);
679
+ errors.push(...result.errors);
680
+ }
681
+ catch (err) {
682
+ errors.push({
683
+ type: name,
684
+ id: "pull",
685
+ error: err instanceof Error ? err.message : String(err),
686
+ });
687
+ }
688
+ }
689
+ return { synced, errors };
217
690
  },
218
691
  });
219
692
  /**
@@ -234,7 +707,7 @@ export const syncGarmin = action({
234
707
  const connection = await ctx.runQuery(internal.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
235
708
  if (!connection) {
236
709
  throw new Error(`No Garmin connection found for user "${args.userId}". ` +
237
- "Call connectGarmin first.");
710
+ "Connect to Garmin first via getGarminAuthUrl.");
238
711
  }
239
712
  if (!connection.active) {
240
713
  throw new Error(`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`);
@@ -298,155 +771,10 @@ export const syncGarmin = action({
298
771
  return result;
299
772
  },
300
773
  });
301
- /**
302
- * Disconnect a user from Garmin.
303
- *
304
- * Deregisters the user via the Garmin API (best-effort), deletes stored
305
- * tokens, and sets the connection to inactive.
306
- */
307
- export const disconnectGarmin = action({
308
- args: {
309
- userId: v.string(),
310
- },
311
- handler: async (ctx, args) => {
312
- const connection = await ctx.runQuery(internal.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
313
- if (!connection) {
314
- throw new Error(`No Garmin connection found for user "${args.userId}".`);
315
- }
316
- const connectionId = connection._id;
317
- // Best-effort: deregister user at Garmin
318
- const tokenDoc = await ctx.runQuery(internal.garmin.private.getTokens, {
319
- connectionId,
320
- });
321
- if (tokenDoc) {
322
- try {
323
- const wellnessClient = createWellnessClient(tokenDoc.accessToken);
324
- await sdkDereg({ client: wellnessClient });
325
- }
326
- catch {
327
- // Deregistration is best-effort; proceed with local cleanup
328
- }
329
- }
330
- const _deleted = await ctx.runMutation(internal.garmin.private.deleteTokens, { connectionId });
331
- const _disconnected = await ctx.runMutation(api.public.disconnect, {
332
- userId: args.userId,
333
- provider: "GARMIN",
334
- });
335
- return null;
336
- },
337
- });
338
- // ─── Training API ────────────────────────────────────────────────────────────
339
- /**
340
- * Push a planned workout from Soma's DB to Garmin Connect.
341
- *
342
- * Reads the planned workout document, transforms it to Garmin Training API V2
343
- * format, creates the workout at Garmin, and optionally schedules it if a
344
- * `planned_date` is set in the metadata.
345
- *
346
- * Returns the Garmin workout ID and schedule ID (if scheduled).
347
- */
348
- export const pushPlannedWorkout = action({
349
- args: {
350
- userId: v.string(),
351
- clientId: v.string(),
352
- clientSecret: v.string(),
353
- plannedWorkoutId: v.string(),
354
- workoutProvider: v.optional(v.string()),
355
- },
356
- handler: async (ctx, args) => {
357
- const connection = await ctx.runQuery(internal.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
358
- if (!connection) {
359
- throw new Error(`No Garmin connection found for user "${args.userId}". ` +
360
- "Call connectGarmin first.");
361
- }
362
- if (!connection.active) {
363
- throw new Error(`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`);
364
- }
365
- const connectionId = connection._id;
366
- const tokenDoc = await ctx.runQuery(internal.garmin.private.getTokens, {
367
- connectionId,
368
- });
369
- if (!tokenDoc) {
370
- throw new Error("No Garmin tokens found for this connection. " +
371
- "The connection may have been created before token storage was available.");
372
- }
373
- // Always force-refresh the token for Training API calls to rule out
374
- // stale tokens (the initial sync swallows 401 errors silently).
375
- let accessToken = tokenDoc.accessToken;
376
- if (tokenDoc.refreshToken) {
377
- try {
378
- const refreshed = await refreshToken({
379
- clientId: args.clientId,
380
- clientSecret: args.clientSecret,
381
- refreshToken: tokenDoc.refreshToken,
382
- });
383
- accessToken = refreshed.access_token;
384
- const nowSeconds = Math.floor(Date.now() / 1000);
385
- const newExpiresAt = nowSeconds + refreshed.expires_in;
386
- const _refreshed = await ctx.runMutation(internal.garmin.private.storeTokens, {
387
- connectionId,
388
- accessToken: refreshed.access_token,
389
- refreshToken: refreshed.refresh_token,
390
- expiresAt: newExpiresAt,
391
- });
392
- }
393
- catch (refreshErr) {
394
- throw new Error(`Garmin token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}. ` +
395
- "The user may need to reconnect their Garmin account.");
396
- }
397
- }
398
- const plannedWorkout = await ctx.runQuery(api.public.getPlannedWorkout, { plannedWorkoutId: args.plannedWorkoutId });
399
- if (!plannedWorkout) {
400
- throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
401
- }
402
- const providerName = args.workoutProvider ?? "Soma";
403
- const garminWorkout = transformPlannedWorkoutToGarmin(plannedWorkout, providerName);
404
- const trainingClient = createTrainingClient(accessToken);
405
- const { data: created, error: createError } = await sdkCreateWorkoutV2({
406
- client: trainingClient,
407
- body: garminWorkout,
408
- });
409
- if (createError || !created) {
410
- throw new Error(`Garmin API error creating workout: ${createError ? JSON.stringify(createError) : "No data"}`);
411
- }
412
- if (!created.workoutId) {
413
- throw new Error("Garmin API did not return a workoutId after creation.");
414
- }
415
- let garminScheduleId = null;
416
- const plannedDate = plannedWorkout.metadata?.planned_date;
417
- if (plannedDate) {
418
- const { data: scheduleId, error: scheduleError } = await sdkCreateWorkoutSchedule({
419
- client: trainingClient,
420
- body: { workoutId: Number(created.workoutId), date: plannedDate },
421
- });
422
- if (scheduleError) {
423
- throw new Error(`Garmin API error creating schedule: ${JSON.stringify(scheduleError)}`);
424
- }
425
- garminScheduleId = scheduleId ?? null;
426
- }
427
- // Store the Garmin workout/schedule IDs back on the planned workout
428
- // so the host app can match completed activities to planned sessions.
429
- const _ingested = await ctx.runMutation(api.public.ingestPlannedWorkout, {
430
- ...plannedWorkout,
431
- _id: undefined,
432
- _creationTime: undefined,
433
- metadata: {
434
- ...plannedWorkout.metadata,
435
- provider_workout_id: String(created.workoutId),
436
- provider_schedule_id: garminScheduleId != null ? String(garminScheduleId) : undefined,
437
- },
438
- });
439
- return {
440
- garminWorkoutId: created.workoutId,
441
- garminScheduleId,
442
- };
443
- },
444
- });
445
- // ─── Sync Engine ────────────────────────────────────────────────────────────
446
774
  /**
447
775
  * Fetch and ingest all Garmin wellness data types for a time range.
448
776
  *
449
- * Called by the public actions (connectGarmin, completeGarminOAuth, syncGarmin)
777
+ * Called by syncGarmin after obtaining a valid access token.
450
778
  * after obtaining a valid access token.
451
779
  */
452
780
  export const syncAllTypes = action({
@@ -822,4 +1150,111 @@ export const syncAllTypes = action({
822
1150
  return { synced, errors };
823
1151
  },
824
1152
  });
1153
+ // ─── Push ───────────────────────────────────────────────────────────────────
1154
+ /**
1155
+ * Push a planned workout from Soma's DB to Garmin Connect.
1156
+ *
1157
+ * Reads the planned workout document, transforms it to Garmin Training API V2
1158
+ * format, creates the workout at Garmin, and optionally schedules it if a
1159
+ * `planned_date` is set in the metadata.
1160
+ *
1161
+ * Returns the Garmin workout ID and schedule ID (if scheduled).
1162
+ */
1163
+ export const pushPlannedWorkout = action({
1164
+ args: {
1165
+ userId: v.string(),
1166
+ clientId: v.string(),
1167
+ clientSecret: v.string(),
1168
+ plannedWorkoutId: v.string(),
1169
+ workoutProvider: v.optional(v.string()),
1170
+ },
1171
+ handler: async (ctx, args) => {
1172
+ const connection = await ctx.runQuery(internal.private.getConnectionByProvider, { userId: args.userId, provider: "GARMIN" });
1173
+ if (!connection) {
1174
+ throw new Error(`No Garmin connection found for user "${args.userId}". ` +
1175
+ "Connect to Garmin first via getGarminAuthUrl.");
1176
+ }
1177
+ if (!connection.active) {
1178
+ throw new Error(`Garmin connection for user "${args.userId}" is inactive. Reconnect first.`);
1179
+ }
1180
+ const connectionId = connection._id;
1181
+ const tokenDoc = await ctx.runQuery(internal.garmin.private.getTokens, {
1182
+ connectionId,
1183
+ });
1184
+ if (!tokenDoc) {
1185
+ throw new Error("No Garmin tokens found for this connection. " +
1186
+ "The connection may have been created before token storage was available.");
1187
+ }
1188
+ // Always force-refresh the token for Training API calls to rule out
1189
+ // stale tokens (the initial sync swallows 401 errors silently).
1190
+ let accessToken = tokenDoc.accessToken;
1191
+ if (tokenDoc.refreshToken) {
1192
+ try {
1193
+ const refreshed = await refreshToken({
1194
+ clientId: args.clientId,
1195
+ clientSecret: args.clientSecret,
1196
+ refreshToken: tokenDoc.refreshToken,
1197
+ });
1198
+ accessToken = refreshed.access_token;
1199
+ const nowSeconds = Math.floor(Date.now() / 1000);
1200
+ const newExpiresAt = nowSeconds + refreshed.expires_in;
1201
+ const _refreshed = await ctx.runMutation(internal.garmin.private.storeTokens, {
1202
+ connectionId,
1203
+ accessToken: refreshed.access_token,
1204
+ refreshToken: refreshed.refresh_token,
1205
+ expiresAt: newExpiresAt,
1206
+ });
1207
+ }
1208
+ catch (refreshErr) {
1209
+ throw new Error(`Garmin token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}. ` +
1210
+ "The user may need to reconnect their Garmin account.");
1211
+ }
1212
+ }
1213
+ const plannedWorkout = await ctx.runQuery(api.public.getPlannedWorkout, { plannedWorkoutId: args.plannedWorkoutId });
1214
+ if (!plannedWorkout) {
1215
+ throw new Error(`Planned workout "${args.plannedWorkoutId}" not found.`);
1216
+ }
1217
+ const providerName = args.workoutProvider ?? "Soma";
1218
+ const garminWorkout = transformPlannedWorkoutToGarmin(plannedWorkout, providerName);
1219
+ const trainingClient = createTrainingClient(accessToken);
1220
+ const { data: created, error: createError } = await sdkCreateWorkoutV2({
1221
+ client: trainingClient,
1222
+ body: garminWorkout,
1223
+ });
1224
+ if (createError || !created) {
1225
+ throw new Error(`Garmin API error creating workout: ${createError ? JSON.stringify(createError) : "No data"}`);
1226
+ }
1227
+ if (!created.workoutId) {
1228
+ throw new Error("Garmin API did not return a workoutId after creation.");
1229
+ }
1230
+ let garminScheduleId = null;
1231
+ const plannedDate = plannedWorkout.metadata?.planned_date;
1232
+ if (plannedDate) {
1233
+ const { data: scheduleId, error: scheduleError } = await sdkCreateWorkoutSchedule({
1234
+ client: trainingClient,
1235
+ body: { workoutId: Number(created.workoutId), date: plannedDate },
1236
+ });
1237
+ if (scheduleError) {
1238
+ throw new Error(`Garmin API error creating schedule: ${JSON.stringify(scheduleError)}`);
1239
+ }
1240
+ garminScheduleId = scheduleId ?? null;
1241
+ }
1242
+ // Store the Garmin workout/schedule IDs back on the planned workout
1243
+ // so the host app can match completed activities to planned sessions.
1244
+ const _ingested = await ctx.runMutation(api.public.ingestPlannedWorkout, {
1245
+ ...plannedWorkout,
1246
+ _id: undefined,
1247
+ _creationTime: undefined,
1248
+ metadata: {
1249
+ ...plannedWorkout.metadata,
1250
+ provider_workout_id: String(created.workoutId),
1251
+ provider_schedule_id: garminScheduleId != null ? String(garminScheduleId) : undefined,
1252
+ },
1253
+ });
1254
+ return {
1255
+ garminWorkoutId: created.workoutId,
1256
+ garminScheduleId,
1257
+ };
1258
+ },
1259
+ });
825
1260
  //# sourceMappingURL=public.js.map