@nativesquare/soma 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/dist/client/index.d.ts +283 -0
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +328 -0
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/api.d.ts +2 -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 +77 -0
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/garmin.d.ts +164 -0
  11. package/dist/component/garmin.d.ts.map +1 -0
  12. package/dist/component/garmin.js +609 -0
  13. package/dist/component/garmin.js.map +1 -0
  14. package/dist/component/public.d.ts +761 -761
  15. package/dist/component/schema.d.ts +405 -388
  16. package/dist/component/schema.d.ts.map +1 -1
  17. package/dist/component/schema.js +14 -2
  18. package/dist/component/schema.js.map +1 -1
  19. package/dist/component/strava.d.ts +5 -4
  20. package/dist/component/strava.d.ts.map +1 -1
  21. package/dist/component/strava.js +18 -1
  22. package/dist/component/strava.js.map +1 -1
  23. package/dist/component/validators/activity.d.ts +42 -42
  24. package/dist/component/validators/body.d.ts +47 -47
  25. package/dist/component/validators/daily.d.ts +17 -17
  26. package/dist/component/validators/plannedWorkout.d.ts +5 -5
  27. package/dist/component/validators/samples.d.ts +2 -2
  28. package/dist/component/validators/shared.d.ts +17 -17
  29. package/dist/component/validators/sleep.d.ts +17 -17
  30. package/dist/garmin/activity.d.ts +101 -0
  31. package/dist/garmin/activity.d.ts.map +1 -0
  32. package/dist/garmin/activity.js +207 -0
  33. package/dist/garmin/activity.js.map +1 -0
  34. package/dist/garmin/auth.d.ts +65 -0
  35. package/dist/garmin/auth.d.ts.map +1 -0
  36. package/dist/garmin/auth.js +155 -0
  37. package/dist/garmin/auth.js.map +1 -0
  38. package/dist/garmin/body.d.ts +26 -0
  39. package/dist/garmin/body.d.ts.map +1 -0
  40. package/dist/garmin/body.js +44 -0
  41. package/dist/garmin/body.js.map +1 -0
  42. package/dist/garmin/client.d.ts +99 -0
  43. package/dist/garmin/client.d.ts.map +1 -0
  44. package/dist/garmin/client.js +153 -0
  45. package/dist/garmin/client.js.map +1 -0
  46. package/dist/garmin/daily.d.ts +74 -0
  47. package/dist/garmin/daily.d.ts.map +1 -0
  48. package/dist/garmin/daily.js +143 -0
  49. package/dist/garmin/daily.js.map +1 -0
  50. package/dist/garmin/index.d.ts +20 -0
  51. package/dist/garmin/index.d.ts.map +1 -0
  52. package/dist/garmin/index.js +21 -0
  53. package/dist/garmin/index.js.map +1 -0
  54. package/dist/garmin/maps/activity-type.d.ts +7 -0
  55. package/dist/garmin/maps/activity-type.d.ts.map +1 -0
  56. package/dist/garmin/maps/activity-type.js +98 -0
  57. package/dist/garmin/maps/activity-type.js.map +1 -0
  58. package/dist/garmin/maps/sleep-level.d.ts +6 -0
  59. package/dist/garmin/maps/sleep-level.d.ts.map +1 -0
  60. package/dist/garmin/maps/sleep-level.js +21 -0
  61. package/dist/garmin/maps/sleep-level.js.map +1 -0
  62. package/dist/garmin/menstruation.d.ts +23 -0
  63. package/dist/garmin/menstruation.d.ts.map +1 -0
  64. package/dist/garmin/menstruation.js +34 -0
  65. package/dist/garmin/menstruation.js.map +1 -0
  66. package/dist/garmin/sleep.d.ts +62 -0
  67. package/dist/garmin/sleep.d.ts.map +1 -0
  68. package/dist/garmin/sleep.js +125 -0
  69. package/dist/garmin/sleep.js.map +1 -0
  70. package/dist/garmin/sync.d.ts +39 -0
  71. package/dist/garmin/sync.d.ts.map +1 -0
  72. package/dist/garmin/sync.js +175 -0
  73. package/dist/garmin/sync.js.map +1 -0
  74. package/dist/garmin/types.d.ts +212 -0
  75. package/dist/garmin/types.d.ts.map +1 -0
  76. package/dist/garmin/types.js +8 -0
  77. package/dist/garmin/types.js.map +1 -0
  78. package/dist/validators.d.ts +331 -331
  79. package/package.json +5 -1
  80. package/src/client/index.ts +446 -1
  81. package/src/component/_generated/api.ts +2 -0
  82. package/src/component/_generated/component.ts +89 -0
  83. package/src/component/garmin.ts +711 -0
  84. package/src/component/schema.ts +15 -2
  85. package/src/component/strava.ts +23 -1
  86. package/src/garmin/activity.test.ts +178 -0
  87. package/src/garmin/activity.ts +272 -0
  88. package/src/garmin/auth.test.ts +128 -0
  89. package/src/garmin/auth.ts +249 -0
  90. package/src/garmin/body.ts +59 -0
  91. package/src/garmin/client.ts +254 -0
  92. package/src/garmin/daily.ts +211 -0
  93. package/src/garmin/index.ts +76 -0
  94. package/src/garmin/maps/activity-type.test.ts +78 -0
  95. package/src/garmin/maps/activity-type.ts +116 -0
  96. package/src/garmin/maps/sleep-level.ts +22 -0
  97. package/src/garmin/menstruation.ts +42 -0
  98. package/src/garmin/sleep.test.ts +110 -0
  99. package/src/garmin/sleep.ts +170 -0
  100. package/src/garmin/sync.ts +223 -0
  101. package/src/garmin/types.ts +338 -0
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "bugs": {
7
7
  "url": "https://github.com/NativeSquare/soma/issues"
8
8
  },
9
- "version": "0.3.0",
9
+ "version": "0.5.0",
10
10
  "license": "Apache-2.0",
11
11
  "keywords": [
12
12
  "convex",
@@ -57,6 +57,10 @@
57
57
  "types": "./dist/strava/index.d.ts",
58
58
  "default": "./dist/strava/index.js"
59
59
  },
60
+ "./garmin": {
61
+ "types": "./dist/garmin/index.d.ts",
62
+ "default": "./dist/garmin/index.js"
63
+ },
60
64
  "./validators": {
61
65
  "types": "./dist/validators.d.ts",
62
66
  "default": "./dist/validators.js"
@@ -1,9 +1,16 @@
1
1
  import type { ComponentApi } from "../component/_generated/component.js";
2
2
  import type { ActionCtx, MutationCtx, QueryCtx } from "./types.js";
3
+ import { httpActionGeneric, type HttpRouter } from "convex/server";
3
4
  import { buildAuthUrl } from "../strava/auth.js";
4
5
 
5
6
  export type SomaComponent = ComponentApi;
6
7
 
8
+ // ─── Default OAuth Callback Paths ────────────────────────────────────────────
9
+ // Used by `registerRoutes` as defaults. Override per-provider in the opts.
10
+
11
+ export const STRAVA_CALLBACK_PATH = "/api/strava/callback";
12
+ export const GARMIN_CALLBACK_PATH = "/api/garmin/callback";
13
+
7
14
  /**
8
15
  * Configuration for the Strava integration.
9
16
  *
@@ -24,6 +31,20 @@ export interface SomaStravaConfig {
24
31
  baseUrl?: string;
25
32
  }
26
33
 
34
+ /**
35
+ * Configuration for the Garmin integration.
36
+ *
37
+ * If not provided to the constructor, the Soma class will attempt to
38
+ * read `GARMIN_CONSUMER_KEY` and `GARMIN_CONSUMER_SECRET` from
39
+ * environment variables automatically.
40
+ */
41
+ export interface SomaGarminConfig {
42
+ /** Your Garmin application's Consumer Key. */
43
+ consumerKey: string;
44
+ /** Your Garmin application's Consumer Secret. */
45
+ consumerSecret: string;
46
+ }
47
+
27
48
  /**
28
49
  * Client class for the @nativesquare/soma Convex component.
29
50
  *
@@ -60,12 +81,14 @@ export interface SomaStravaConfig {
60
81
  */
61
82
  export class Soma {
62
83
  private stravaConfig?: SomaStravaConfig;
84
+ private garminConfig?: SomaGarminConfig;
63
85
 
64
86
  constructor(
65
87
  public component: SomaComponent,
66
- options?: { strava?: SomaStravaConfig },
88
+ options?: { strava?: SomaStravaConfig; garmin?: SomaGarminConfig },
67
89
  ) {
68
90
  this.stravaConfig = options?.strava ?? this.readStravaEnv();
91
+ this.garminConfig = options?.garmin ?? this.readGarminEnv();
69
92
  }
70
93
 
71
94
  /**
@@ -97,6 +120,31 @@ export class Soma {
97
120
  return this.stravaConfig;
98
121
  }
99
122
 
123
+ /**
124
+ * Read Garmin config from environment variables.
125
+ * Returns undefined if the required vars are not set.
126
+ */
127
+ private readGarminEnv(): SomaGarminConfig | undefined {
128
+ const consumerKey = process.env.GARMIN_CONSUMER_KEY;
129
+ const consumerSecret = process.env.GARMIN_CONSUMER_SECRET;
130
+ if (!consumerKey || !consumerSecret) return undefined;
131
+ return { consumerKey, consumerSecret };
132
+ }
133
+
134
+ /**
135
+ * Get the resolved Garmin config, or throw a clear error if not configured.
136
+ */
137
+ private requireGarminConfig(): SomaGarminConfig {
138
+ if (!this.garminConfig) {
139
+ throw new Error(
140
+ "Garmin is not configured. Either set GARMIN_CONSUMER_KEY and " +
141
+ "GARMIN_CONSUMER_SECRET environment variables in the Convex dashboard, " +
142
+ "or pass { garmin: { consumerKey, consumerSecret } } to the Soma constructor.",
143
+ );
144
+ }
145
+ return this.garminConfig;
146
+ }
147
+
100
148
  // ─── Connect / Disconnect ───────────────────────────────────────────────────
101
149
 
102
150
  /**
@@ -820,6 +868,192 @@ export class Soma {
820
868
  baseUrl: config.baseUrl,
821
869
  });
822
870
  }
871
+
872
+ // ─── Garmin Integration ──────────────────────────────────────────────────────
873
+ // High-level methods that handle OAuth 1.0a, token storage, and data syncing
874
+ // for Garmin. Requires Garmin credentials to be configured either via
875
+ // environment variables or the constructor.
876
+
877
+ /**
878
+ * Step 1 of the Garmin OAuth 1.0a flow: obtain a request token.
879
+ *
880
+ * Returns the temporary `token`, `tokenSecret`, and the `authUrl` to
881
+ * redirect the user to.
882
+ *
883
+ * If `userId` is provided, the pending OAuth state is stored inside the
884
+ * component automatically, and the callback handler registered by
885
+ * `registerRoutes` will complete the flow without further host-app
886
+ * intervention. This is the recommended approach.
887
+ *
888
+ * If `userId` is omitted, the host app must store `token` and `tokenSecret`
889
+ * itself and pass them to `connectGarmin` manually.
890
+ *
891
+ * @param ctx - Action context from the host app
892
+ * @param opts.callbackUrl - The URL Garmin will redirect to after authorization
893
+ * @param opts.userId - The host app's user identifier (required for `registerRoutes` flow)
894
+ * @returns `{ token, tokenSecret, authUrl }`
895
+ *
896
+ * @example
897
+ * ```ts
898
+ * // Recommended: pass userId so registerRoutes can handle the callback
899
+ * const { authUrl } = await soma.getGarminRequestToken(ctx, {
900
+ * userId: "user_123",
901
+ * callbackUrl: "https://your-app.convex.site/api/garmin/callback",
902
+ * });
903
+ * // Redirect user to authUrl — the callback is handled automatically
904
+ * ```
905
+ */
906
+ async getGarminRequestToken(
907
+ ctx: ActionCtx,
908
+ opts: { callbackUrl?: string; userId?: string },
909
+ ) {
910
+ const config = this.requireGarminConfig();
911
+ return await ctx.runAction(this.component.garmin.getGarminRequestToken, {
912
+ consumerKey: config.consumerKey,
913
+ consumerSecret: config.consumerSecret,
914
+ callbackUrl: opts.callbackUrl,
915
+ userId: opts.userId,
916
+ });
917
+ }
918
+
919
+ /**
920
+ * Step 3 of the Garmin OAuth 1.0a flow + initial data sync.
921
+ *
922
+ * Exchanges the request token + verifier for permanent access tokens,
923
+ * creates/reactivates the Soma connection, stores tokens securely,
924
+ * and syncs the last 30 days of all data types (activities, dailies,
925
+ * sleep, body composition, menstruation).
926
+ *
927
+ * Call this from your OAuth callback endpoint after receiving the
928
+ * `oauth_verifier` query parameter from Garmin.
929
+ *
930
+ * @param ctx - Action context from the host app
931
+ * @param args.userId - The host app's user identifier
932
+ * @param args.token - The request token from Step 1
933
+ * @param args.tokenSecret - The request token secret from Step 1
934
+ * @param args.verifier - The oauth_verifier from the callback
935
+ * @returns `{ connectionId, synced, errors }`
936
+ *
937
+ * @example
938
+ * ```ts
939
+ * export const handleGarminCallback = action({
940
+ * args: {
941
+ * userId: v.string(),
942
+ * token: v.string(),
943
+ * tokenSecret: v.string(),
944
+ * verifier: v.string(),
945
+ * },
946
+ * handler: async (ctx, args) => {
947
+ * return await soma.connectGarmin(ctx, args);
948
+ * },
949
+ * });
950
+ * ```
951
+ */
952
+ async connectGarmin(
953
+ ctx: ActionCtx,
954
+ args: {
955
+ userId: string;
956
+ token: string;
957
+ tokenSecret: string;
958
+ verifier: string;
959
+ },
960
+ ) {
961
+ const config = this.requireGarminConfig();
962
+ return await ctx.runAction(this.component.garmin.connectGarmin, {
963
+ ...args,
964
+ consumerKey: config.consumerKey,
965
+ consumerSecret: config.consumerSecret,
966
+ });
967
+ }
968
+
969
+ /**
970
+ * Complete a Garmin OAuth flow using stored pending state.
971
+ *
972
+ * This is called automatically by the `registerRoutes` callback handler.
973
+ * It looks up the pending OAuth state stored during `getGarminRequestToken`,
974
+ * exchanges for permanent tokens, creates the connection, and syncs data.
975
+ *
976
+ * @param ctx - Action context from the host app
977
+ * @param args.oauthToken - The oauth_token from the callback query params
978
+ * @param args.oauthVerifier - The oauth_verifier from the callback query params
979
+ * @returns `{ connectionId, synced, errors }`
980
+ */
981
+ async completeGarminOAuth(
982
+ ctx: ActionCtx,
983
+ args: { oauthToken: string; oauthVerifier: string },
984
+ ) {
985
+ const config = this.requireGarminConfig();
986
+ return await ctx.runAction(this.component.garmin.completeGarminOAuth, {
987
+ ...args,
988
+ consumerKey: config.consumerKey,
989
+ consumerSecret: config.consumerSecret,
990
+ });
991
+ }
992
+
993
+ /**
994
+ * Sync all data types from Garmin for an already-connected user.
995
+ *
996
+ * Fetches activities, dailies, sleep, body composition, and menstruation
997
+ * data for the specified time range (defaults to last 30 days).
998
+ *
999
+ * @param ctx - Action context from the host app
1000
+ * @param args.userId - The host app's user identifier
1001
+ * @param args.startTimeInSeconds - Optional start of time range (Unix epoch seconds)
1002
+ * @param args.endTimeInSeconds - Optional end of time range (Unix epoch seconds)
1003
+ * @returns `{ synced, errors }`
1004
+ *
1005
+ * @example
1006
+ * ```ts
1007
+ * export const syncGarmin = action({
1008
+ * args: { userId: v.string() },
1009
+ * handler: async (ctx, { userId }) => {
1010
+ * return await soma.syncGarmin(ctx, { userId });
1011
+ * },
1012
+ * });
1013
+ * ```
1014
+ */
1015
+ async syncGarmin(
1016
+ ctx: ActionCtx,
1017
+ args: {
1018
+ userId: string;
1019
+ startTimeInSeconds?: number;
1020
+ endTimeInSeconds?: number;
1021
+ },
1022
+ ) {
1023
+ const config = this.requireGarminConfig();
1024
+ return await ctx.runAction(this.component.garmin.syncGarmin, {
1025
+ ...args,
1026
+ consumerKey: config.consumerKey,
1027
+ consumerSecret: config.consumerSecret,
1028
+ });
1029
+ }
1030
+
1031
+ /**
1032
+ * Disconnect a user from Garmin.
1033
+ *
1034
+ * Deletes stored tokens and sets the connection to inactive.
1035
+ * Garmin OAuth 1.0a tokens cannot be revoked via API, so cleanup
1036
+ * is local only.
1037
+ *
1038
+ * @param ctx - Action context from the host app
1039
+ * @param args.userId - The host app's user identifier
1040
+ *
1041
+ * @example
1042
+ * ```ts
1043
+ * export const disconnectGarmin = action({
1044
+ * args: { userId: v.string() },
1045
+ * handler: async (ctx, { userId }) => {
1046
+ * await soma.disconnectGarmin(ctx, { userId });
1047
+ * },
1048
+ * });
1049
+ * ```
1050
+ */
1051
+ async disconnectGarmin(
1052
+ ctx: ActionCtx,
1053
+ args: { userId: string },
1054
+ ) {
1055
+ return await ctx.runAction(this.component.garmin.disconnectGarmin, args);
1056
+ }
823
1057
  }
824
1058
 
825
1059
  // ─── Shared Types ────────────────────────────────────────────────────────────
@@ -862,3 +1096,214 @@ type ListTimeRangeArgs = TimeRangeArgs & {
862
1096
  type PaginateTimeRangeArgs = TimeRangeArgs & {
863
1097
  paginationOpts: { numItems: number; cursor: string | null };
864
1098
  };
1099
+
1100
+ // ─── Route Registration ──────────────────────────────────────────────────────
1101
+
1102
+ /**
1103
+ * Per-provider options for `registerRoutes`.
1104
+ */
1105
+ export interface StravaRouteOptions {
1106
+ /** HTTP path for the OAuth callback. @default "/api/strava/callback" */
1107
+ path?: string;
1108
+ /** Override STRAVA_CLIENT_ID env var. */
1109
+ clientId?: string;
1110
+ /** Override STRAVA_CLIENT_SECRET env var. */
1111
+ clientSecret?: string;
1112
+ /** Override STRAVA_BASE_URL env var. */
1113
+ baseUrl?: string;
1114
+ /** URL to redirect the user to after a successful connection. */
1115
+ onSuccess?: string;
1116
+ }
1117
+
1118
+ export interface GarminRouteOptions {
1119
+ /** HTTP path for the OAuth callback. @default "/api/garmin/callback" */
1120
+ path?: string;
1121
+ /** Override GARMIN_CONSUMER_KEY env var. */
1122
+ consumerKey?: string;
1123
+ /** Override GARMIN_CONSUMER_SECRET env var. */
1124
+ consumerSecret?: string;
1125
+ /** URL to redirect the user to after a successful connection. */
1126
+ onSuccess?: string;
1127
+ }
1128
+
1129
+ export interface RegisterRoutesOptions {
1130
+ strava?: StravaRouteOptions;
1131
+ garmin?: GarminRouteOptions;
1132
+ }
1133
+
1134
+ /**
1135
+ * Register OAuth callback HTTP routes for Soma providers.
1136
+ *
1137
+ * Call this from your `convex/http.ts` to set up the callback endpoints
1138
+ * that Strava and Garmin redirect to after user authorization. The handlers
1139
+ * complete the OAuth exchange, create the connection, and sync data
1140
+ * automatically.
1141
+ *
1142
+ * When called with no `opts`, registers both Strava and Garmin routes with
1143
+ * default paths and credentials from environment variables. When `opts` is
1144
+ * provided, only registers routes for the providers specified.
1145
+ *
1146
+ * @param http - The Convex HTTP router from `httpRouter()`
1147
+ * @param component - The Soma component reference (`components.soma`)
1148
+ * @param opts - Optional per-provider configuration
1149
+ *
1150
+ * @example
1151
+ * ```ts
1152
+ * // convex/http.ts
1153
+ * import { httpRouter } from "convex/server";
1154
+ * import { registerRoutes } from "@nativesquare/soma";
1155
+ * import { components } from "./_generated/api";
1156
+ *
1157
+ * const http = httpRouter();
1158
+ * registerRoutes(http, components.soma);
1159
+ * export default http;
1160
+ * ```
1161
+ *
1162
+ * @example
1163
+ * ```ts
1164
+ * // With custom paths and redirect
1165
+ * registerRoutes(http, components.soma, {
1166
+ * strava: {
1167
+ * path: "/oauth/strava/callback",
1168
+ * onSuccess: "https://myapp.com/settings",
1169
+ * },
1170
+ * garmin: {
1171
+ * path: "/oauth/garmin/callback",
1172
+ * onSuccess: "https://myapp.com/settings",
1173
+ * },
1174
+ * });
1175
+ * ```
1176
+ */
1177
+ export function registerRoutes(
1178
+ http: HttpRouter,
1179
+ component: SomaComponent,
1180
+ opts?: RegisterRoutesOptions,
1181
+ ) {
1182
+ const registerAll = opts === undefined;
1183
+
1184
+ if (registerAll || opts?.strava) {
1185
+ const strava = opts?.strava ?? {};
1186
+ const path = strava.path ?? STRAVA_CALLBACK_PATH;
1187
+
1188
+ http.route({
1189
+ path,
1190
+ method: "GET",
1191
+ handler: httpActionGeneric(async (ctx, request) => {
1192
+ const url = new URL(request.url);
1193
+ const code = url.searchParams.get("code");
1194
+ const userId = url.searchParams.get("state");
1195
+
1196
+ if (!code) {
1197
+ return new Response("Missing authorization code", { status: 400 });
1198
+ }
1199
+ if (!userId) {
1200
+ return new Response(
1201
+ "Missing state parameter (userId). Pass the userId as the state " +
1202
+ "parameter when building the Strava auth URL.",
1203
+ { status: 400 },
1204
+ );
1205
+ }
1206
+
1207
+ const clientId =
1208
+ strava.clientId ?? process.env.STRAVA_CLIENT_ID;
1209
+ const clientSecret =
1210
+ strava.clientSecret ?? process.env.STRAVA_CLIENT_SECRET;
1211
+
1212
+ if (!clientId || !clientSecret) {
1213
+ return new Response(
1214
+ "Strava credentials not configured. Set STRAVA_CLIENT_ID and " +
1215
+ "STRAVA_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
1216
+ { status: 500 },
1217
+ );
1218
+ }
1219
+
1220
+ try {
1221
+ await ctx.runAction(component.strava.connectStrava, {
1222
+ userId,
1223
+ code,
1224
+ clientId,
1225
+ clientSecret,
1226
+ baseUrl: strava.baseUrl ?? process.env.STRAVA_BASE_URL,
1227
+ });
1228
+ } catch (error) {
1229
+ const message =
1230
+ error instanceof Error ? error.message : "Unknown error";
1231
+ return new Response(`Strava OAuth callback failed: ${message}`, {
1232
+ status: 500,
1233
+ });
1234
+ }
1235
+
1236
+ if (strava.onSuccess) {
1237
+ return new Response(null, {
1238
+ status: 302,
1239
+ headers: { Location: strava.onSuccess },
1240
+ });
1241
+ }
1242
+
1243
+ return new Response("Successfully connected to Strava!", {
1244
+ status: 200,
1245
+ });
1246
+ }),
1247
+ });
1248
+ }
1249
+
1250
+ if (registerAll || opts?.garmin) {
1251
+ const garmin = opts?.garmin ?? {};
1252
+ const path = garmin.path ?? GARMIN_CALLBACK_PATH;
1253
+
1254
+ http.route({
1255
+ path,
1256
+ method: "GET",
1257
+ handler: httpActionGeneric(async (ctx, request) => {
1258
+ const url = new URL(request.url);
1259
+ const oauthToken = url.searchParams.get("oauth_token");
1260
+ const oauthVerifier = url.searchParams.get("oauth_verifier");
1261
+
1262
+ if (!oauthToken || !oauthVerifier) {
1263
+ return new Response("Missing oauth_token or oauth_verifier", {
1264
+ status: 400,
1265
+ });
1266
+ }
1267
+
1268
+ const consumerKey =
1269
+ garmin.consumerKey ?? process.env.GARMIN_CONSUMER_KEY;
1270
+ const consumerSecret =
1271
+ garmin.consumerSecret ?? process.env.GARMIN_CONSUMER_SECRET;
1272
+
1273
+ if (!consumerKey || !consumerSecret) {
1274
+ return new Response(
1275
+ "Garmin credentials not configured. Set GARMIN_CONSUMER_KEY and " +
1276
+ "GARMIN_CONSUMER_SECRET environment variables, or pass them to registerRoutes.",
1277
+ { status: 500 },
1278
+ );
1279
+ }
1280
+
1281
+ try {
1282
+ await ctx.runAction(component.garmin.completeGarminOAuth, {
1283
+ oauthToken,
1284
+ oauthVerifier,
1285
+ consumerKey,
1286
+ consumerSecret,
1287
+ });
1288
+ } catch (error) {
1289
+ const message =
1290
+ error instanceof Error ? error.message : "Unknown error";
1291
+ return new Response(`Garmin OAuth callback failed: ${message}`, {
1292
+ status: 500,
1293
+ });
1294
+ }
1295
+
1296
+ if (garmin.onSuccess) {
1297
+ return new Response(null, {
1298
+ status: 302,
1299
+ headers: { Location: garmin.onSuccess },
1300
+ });
1301
+ }
1302
+
1303
+ return new Response("Successfully connected to Garmin!", {
1304
+ status: 200,
1305
+ });
1306
+ }),
1307
+ });
1308
+ }
1309
+ }
@@ -8,6 +8,7 @@
8
8
  * @module
9
9
  */
10
10
 
11
+ import type * as garmin from "../garmin.js";
11
12
  import type * as private_ from "../private.js";
12
13
  import type * as public_ from "../public.js";
13
14
  import type * as strava from "../strava.js";
@@ -33,6 +34,7 @@ import type {
33
34
  import { anyApi, componentsGeneric } from "convex/server";
34
35
 
35
36
  const fullApi: ApiFromModules<{
37
+ garmin: typeof garmin;
36
38
  private: typeof private_;
37
39
  public: typeof public_;
38
40
  strava: typeof strava;
@@ -23,6 +23,95 @@ import type { FunctionReference } from "convex/server";
23
23
  */
24
24
  export type ComponentApi<Name extends string | undefined = string | undefined> =
25
25
  {
26
+ garmin: {
27
+ completeGarminOAuth: FunctionReference<
28
+ "action",
29
+ "internal",
30
+ {
31
+ consumerKey: string;
32
+ consumerSecret: string;
33
+ oauthToken: string;
34
+ oauthVerifier: string;
35
+ },
36
+ {
37
+ connectionId: string;
38
+ errors: Array<{ error: string; id: string; type: string }>;
39
+ synced: {
40
+ activities: number;
41
+ body: number;
42
+ dailies: number;
43
+ menstruation: number;
44
+ sleep: number;
45
+ };
46
+ },
47
+ Name
48
+ >;
49
+ connectGarmin: FunctionReference<
50
+ "action",
51
+ "internal",
52
+ {
53
+ consumerKey: string;
54
+ consumerSecret: string;
55
+ token: string;
56
+ tokenSecret: string;
57
+ userId: string;
58
+ verifier: string;
59
+ },
60
+ {
61
+ connectionId: string;
62
+ errors: Array<{ error: string; id: string; type: string }>;
63
+ synced: {
64
+ activities: number;
65
+ body: number;
66
+ dailies: number;
67
+ menstruation: number;
68
+ sleep: number;
69
+ };
70
+ },
71
+ Name
72
+ >;
73
+ disconnectGarmin: FunctionReference<
74
+ "action",
75
+ "internal",
76
+ { userId: string },
77
+ null,
78
+ Name
79
+ >;
80
+ getGarminRequestToken: FunctionReference<
81
+ "action",
82
+ "internal",
83
+ {
84
+ callbackUrl?: string;
85
+ consumerKey: string;
86
+ consumerSecret: string;
87
+ userId?: string;
88
+ },
89
+ { authUrl: string; token: string; tokenSecret: string },
90
+ Name
91
+ >;
92
+ syncGarmin: FunctionReference<
93
+ "action",
94
+ "internal",
95
+ {
96
+ consumerKey: string;
97
+ consumerSecret: string;
98
+ endTimeInSeconds?: number;
99
+ startTimeInSeconds?: number;
100
+ userId: string;
101
+ },
102
+ {
103
+ errors: Array<{ error: string; id: string; type: string }>;
104
+ synced: {
105
+ activities: number;
106
+ body: number;
107
+ dailies: number;
108
+ menstruation: number;
109
+ sleep: number;
110
+ };
111
+ },
112
+ Name
113
+ >;
114
+ };
26
115
  public: {
27
116
  connect: FunctionReference<
28
117
  "mutation",