@nativesquare/soma 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/client/index.d.ts +151 -53
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +162 -69
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/component.d.ts +130 -17
  6. package/dist/component/_generated/component.d.ts.map +1 -1
  7. package/dist/component/garmin.d.ts +61 -43
  8. package/dist/component/garmin.d.ts.map +1 -1
  9. package/dist/component/garmin.js +208 -122
  10. package/dist/component/garmin.js.map +1 -1
  11. package/dist/component/public.d.ts +363 -0
  12. package/dist/component/public.d.ts.map +1 -1
  13. package/dist/component/public.js +124 -0
  14. package/dist/component/public.js.map +1 -1
  15. package/dist/component/schema.d.ts +7 -9
  16. package/dist/component/schema.d.ts.map +1 -1
  17. package/dist/component/schema.js +9 -10
  18. package/dist/component/schema.js.map +1 -1
  19. package/dist/component/strava.d.ts +0 -1
  20. package/dist/component/strava.d.ts.map +1 -1
  21. package/dist/component/strava.js +0 -1
  22. package/dist/component/strava.js.map +1 -1
  23. package/dist/component/validators/enums.d.ts +1 -1
  24. package/dist/garmin/auth.d.ts +55 -46
  25. package/dist/garmin/auth.d.ts.map +1 -1
  26. package/dist/garmin/auth.js +82 -122
  27. package/dist/garmin/auth.js.map +1 -1
  28. package/dist/garmin/client.d.ts +64 -17
  29. package/dist/garmin/client.d.ts.map +1 -1
  30. package/dist/garmin/client.js +143 -29
  31. package/dist/garmin/client.js.map +1 -1
  32. package/dist/garmin/index.d.ts +3 -3
  33. package/dist/garmin/index.d.ts.map +1 -1
  34. package/dist/garmin/index.js +4 -4
  35. package/dist/garmin/index.js.map +1 -1
  36. package/dist/garmin/plannedWorkout.d.ts +12 -0
  37. package/dist/garmin/plannedWorkout.d.ts.map +1 -0
  38. package/dist/garmin/plannedWorkout.js +267 -0
  39. package/dist/garmin/plannedWorkout.js.map +1 -0
  40. package/dist/garmin/types.d.ts +78 -6
  41. package/dist/garmin/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/client/index.ts +236 -85
  44. package/src/component/_generated/component.ts +155 -17
  45. package/src/component/garmin.ts +258 -124
  46. package/src/component/public.ts +135 -0
  47. package/src/component/schema.ts +9 -10
  48. package/src/component/strava.ts +0 -1
  49. package/src/garmin/auth.test.ts +71 -96
  50. package/src/garmin/auth.ts +129 -193
  51. package/src/garmin/client.ts +197 -51
  52. package/src/garmin/index.ts +13 -14
  53. package/src/garmin/plannedWorkout.ts +333 -0
  54. package/src/garmin/types.ts +149 -7
@@ -35,14 +35,14 @@ export interface SomaStravaConfig {
35
35
  * Configuration for the Garmin integration.
36
36
  *
37
37
  * If not provided to the constructor, the Soma class will attempt to
38
- * read `GARMIN_CONSUMER_KEY` and `GARMIN_CONSUMER_SECRET` from
38
+ * read `GARMIN_CLIENT_ID` and `GARMIN_CLIENT_SECRET` from
39
39
  * environment variables automatically.
40
40
  */
41
41
  export interface SomaGarminConfig {
42
- /** Your Garmin application's Consumer Key. */
43
- consumerKey: string;
44
- /** Your Garmin application's Consumer Secret. */
45
- consumerSecret: string;
42
+ /** Your Garmin application's Client ID. */
43
+ clientId: string;
44
+ /** Your Garmin application's Client Secret. */
45
+ clientSecret: string;
46
46
  }
47
47
 
48
48
  /**
@@ -125,10 +125,10 @@ export class Soma {
125
125
  * Returns undefined if the required vars are not set.
126
126
  */
127
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 };
128
+ const clientId = process.env.GARMIN_CLIENT_ID;
129
+ const clientSecret = process.env.GARMIN_CLIENT_SECRET;
130
+ if (!clientId || !clientSecret) return undefined;
131
+ return { clientId, clientSecret };
132
132
  }
133
133
 
134
134
  /**
@@ -137,9 +137,9 @@ export class Soma {
137
137
  private requireGarminConfig(): SomaGarminConfig {
138
138
  if (!this.garminConfig) {
139
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.",
140
+ "Garmin is not configured. Either set GARMIN_CLIENT_ID and " +
141
+ "GARMIN_CLIENT_SECRET environment variables in the Convex dashboard, " +
142
+ "or pass { garmin: { clientId, clientSecret } } to the Soma constructor.",
143
143
  );
144
144
  }
145
145
  return this.garminConfig;
@@ -725,6 +725,153 @@ export class Soma {
725
725
  return await ctx.runQuery(this.component.public.getAthlete, args);
726
726
  }
727
727
 
728
+ // ── Planned Workouts ────────────────────────────────────────────────────────
729
+
730
+ /**
731
+ * Ingest a planned workout record.
732
+ *
733
+ * Upserts by `connectionId + metadata.id` when an id is present.
734
+ *
735
+ * @param ctx - Mutation context from the host app
736
+ * @param args - Planned workout data including connectionId, userId, metadata, and steps
737
+ * @returns The planned workout document ID
738
+ */
739
+ async ingestPlannedWorkout(
740
+ ctx: MutationCtx,
741
+ args: IngestArgs,
742
+ ): Promise<string> {
743
+ return await ctx.runMutation(
744
+ this.component.public.ingestPlannedWorkout,
745
+ args as never,
746
+ );
747
+ }
748
+
749
+ /**
750
+ * List planned workout records for a user, optionally filtered by planned date range.
751
+ *
752
+ * @param ctx - Query context from the host app
753
+ * @param args.userId - The host app's user identifier
754
+ * @param args.startDate - Optional lower bound (inclusive) on metadata.planned_date (YYYY-MM-DD)
755
+ * @param args.endDate - Optional upper bound (inclusive) on metadata.planned_date (YYYY-MM-DD)
756
+ * @param args.order - Sort order: "asc" or "desc" (default: "desc")
757
+ * @param args.limit - Optional max number of results to return
758
+ */
759
+ async listPlannedWorkouts(
760
+ ctx: QueryCtx,
761
+ args: {
762
+ userId: string;
763
+ startDate?: string;
764
+ endDate?: string;
765
+ order?: "asc" | "desc";
766
+ limit?: number;
767
+ },
768
+ ) {
769
+ return await ctx.runQuery(
770
+ this.component.public.listPlannedWorkouts,
771
+ args,
772
+ );
773
+ }
774
+
775
+ /**
776
+ * Paginate planned workout records for a user, optionally filtered by planned date range.
777
+ *
778
+ * Returns `{ page, isDone, continueCursor }` for cursor-based pagination.
779
+ *
780
+ * @param ctx - Query context from the host app
781
+ * @param args.userId - The host app's user identifier
782
+ * @param args.startDate - Optional lower bound (inclusive) on metadata.planned_date
783
+ * @param args.endDate - Optional upper bound (inclusive) on metadata.planned_date
784
+ * @param args.paginationOpts - Convex pagination options `{ numItems, cursor }`
785
+ */
786
+ async paginatePlannedWorkouts(
787
+ ctx: QueryCtx,
788
+ args: {
789
+ userId: string;
790
+ startDate?: string;
791
+ endDate?: string;
792
+ paginationOpts: { numItems: number; cursor: string | null };
793
+ },
794
+ ) {
795
+ return await ctx.runQuery(
796
+ this.component.public.paginatePlannedWorkouts,
797
+ args,
798
+ );
799
+ }
800
+
801
+ /**
802
+ * Delete a planned workout by document ID.
803
+ *
804
+ * @param ctx - Mutation context from the host app
805
+ * @param args.plannedWorkoutId - The planned workout document ID
806
+ */
807
+ async deletePlannedWorkout(
808
+ ctx: MutationCtx,
809
+ args: { plannedWorkoutId: string },
810
+ ) {
811
+ return await ctx.runMutation(
812
+ this.component.public.deletePlannedWorkout,
813
+ args as never,
814
+ );
815
+ }
816
+
817
+ /**
818
+ * Get a single planned workout by document ID.
819
+ *
820
+ * @param ctx - Query context from the host app
821
+ * @param args.plannedWorkoutId - The planned workout document ID
822
+ */
823
+ async getPlannedWorkout(
824
+ ctx: QueryCtx,
825
+ args: { plannedWorkoutId: string },
826
+ ) {
827
+ return await ctx.runQuery(
828
+ this.component.public.getPlannedWorkout,
829
+ args as never,
830
+ );
831
+ }
832
+
833
+ /**
834
+ * Push a planned workout to Garmin Connect.
835
+ *
836
+ * Creates the workout on Garmin's Training API and optionally schedules it
837
+ * to the user's calendar if `metadata.planned_date` is set. The workout
838
+ * will appear on the user's Garmin device after they sync.
839
+ *
840
+ * @param ctx - Action context from the host app
841
+ * @param args.userId - The host app's user identifier
842
+ * @param args.plannedWorkoutId - The Soma planned workout document ID
843
+ * @param args.workoutProvider - Name shown to user in Garmin Connect (default: "Soma", 20 chars max)
844
+ * @returns `{ garminWorkoutId, garminScheduleId }`
845
+ *
846
+ * @example
847
+ * ```ts
848
+ * export const pushWorkout = action({
849
+ * args: { userId: v.string(), workoutId: v.string() },
850
+ * handler: async (ctx, { userId, workoutId }) => {
851
+ * return await soma.pushPlannedWorkoutToGarmin(ctx, {
852
+ * userId,
853
+ * plannedWorkoutId: workoutId,
854
+ * });
855
+ * },
856
+ * });
857
+ * ```
858
+ */
859
+ async pushPlannedWorkoutToGarmin(
860
+ ctx: ActionCtx,
861
+ args: {
862
+ userId: string;
863
+ plannedWorkoutId: string;
864
+ workoutProvider?: string;
865
+ },
866
+ ) {
867
+ const config = this.requireGarminConfig();
868
+ return await ctx.runAction(this.component.garmin.pushPlannedWorkout, {
869
+ ...args,
870
+ clientId: config.clientId,
871
+ clientSecret: config.clientSecret,
872
+ });
873
+ }
874
+
728
875
  // ─── Strava Integration ──────────────────────────────────────────────────────
729
876
  // High-level methods that handle OAuth, token storage, and data syncing
730
877
  // for Strava. Requires Strava credentials to be configured either via
@@ -870,68 +1017,65 @@ export class Soma {
870
1017
  }
871
1018
 
872
1019
  // ─── 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.
1020
+ // High-level methods that handle OAuth 2.0 PKCE, token storage, and data
1021
+ // syncing for Garmin. Requires Garmin credentials to be configured either
1022
+ // via environment variables or the constructor.
876
1023
 
877
1024
  /**
878
- * Step 1 of the Garmin OAuth 1.0a flow: obtain a request token.
1025
+ * Generate a Garmin OAuth 2.0 authorization URL with PKCE.
879
1026
  *
880
- * Returns the temporary `token`, `tokenSecret`, and the `authUrl` to
881
- * redirect the user to.
1027
+ * Returns the `authUrl` to redirect the user to, along with the `state`
1028
+ * and `codeVerifier` used for the PKCE flow.
882
1029
  *
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.
1030
+ * If `userId` is provided, the PKCE state is stored inside the component
1031
+ * automatically, and the callback handler registered by `registerRoutes`
1032
+ * will complete the flow without further host-app intervention. This is
1033
+ * the recommended approach.
887
1034
  *
888
- * If `userId` is omitted, the host app must store `token` and `tokenSecret`
889
- * itself and pass them to `connectGarmin` manually.
1035
+ * If `userId` is omitted, the host app must store the returned
1036
+ * `codeVerifier` itself and pass it to `connectGarmin` manually.
890
1037
  *
891
1038
  * @param ctx - Action context from the host app
892
- * @param opts.callbackUrl - The URL Garmin will redirect to after authorization
1039
+ * @param opts.redirectUri - The URL Garmin will redirect to after authorization
893
1040
  * @param opts.userId - The host app's user identifier (required for `registerRoutes` flow)
894
- * @returns `{ token, tokenSecret, authUrl }`
1041
+ * @returns `{ authUrl, state, codeVerifier }`
895
1042
  *
896
1043
  * @example
897
1044
  * ```ts
898
- * // Recommended: pass userId so registerRoutes can handle the callback
899
- * const { authUrl } = await soma.getGarminRequestToken(ctx, {
1045
+ * const { authUrl } = await soma.getGarminAuthUrl(ctx, {
900
1046
  * userId: "user_123",
901
- * callbackUrl: "https://your-app.convex.site/api/garmin/callback",
1047
+ * redirectUri: "https://your-app.convex.site/api/garmin/callback",
902
1048
  * });
903
1049
  * // Redirect user to authUrl — the callback is handled automatically
904
1050
  * ```
905
1051
  */
906
- async getGarminRequestToken(
1052
+ async getGarminAuthUrl(
907
1053
  ctx: ActionCtx,
908
- opts: { callbackUrl?: string; userId?: string },
1054
+ opts: { redirectUri?: string; userId?: string },
909
1055
  ) {
910
1056
  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,
1057
+ return await ctx.runAction(this.component.garmin.getGarminAuthUrl, {
1058
+ clientId: config.clientId,
1059
+ redirectUri: opts.redirectUri,
915
1060
  userId: opts.userId,
916
1061
  });
917
1062
  }
918
1063
 
919
1064
  /**
920
- * Step 3 of the Garmin OAuth 1.0a flow + initial data sync.
1065
+ * Handle the Garmin OAuth 2.0 callback (manual flow).
921
1066
  *
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).
1067
+ * Exchanges the authorization code for tokens, creates/reactivates the
1068
+ * Soma connection, stores tokens securely, and syncs the last 30 days
1069
+ * of all data types.
926
1070
  *
927
- * Call this from your OAuth callback endpoint after receiving the
928
- * `oauth_verifier` query parameter from Garmin.
1071
+ * Call this from your OAuth callback endpoint after receiving the `code`
1072
+ * query parameter from Garmin.
929
1073
  *
930
1074
  * @param ctx - Action context from the host app
931
1075
  * @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
1076
+ * @param args.code - The authorization code from the callback
1077
+ * @param args.codeVerifier - The PKCE code verifier from Step 1
1078
+ * @param args.redirectUri - The redirect URI used in the authorization request
935
1079
  * @returns `{ connectionId, synced, errors }`
936
1080
  *
937
1081
  * @example
@@ -939,9 +1083,8 @@ export class Soma {
939
1083
  * export const handleGarminCallback = action({
940
1084
  * args: {
941
1085
  * userId: v.string(),
942
- * token: v.string(),
943
- * tokenSecret: v.string(),
944
- * verifier: v.string(),
1086
+ * code: v.string(),
1087
+ * codeVerifier: v.string(),
945
1088
  * },
946
1089
  * handler: async (ctx, args) => {
947
1090
  * return await soma.connectGarmin(ctx, args);
@@ -953,40 +1096,41 @@ export class Soma {
953
1096
  ctx: ActionCtx,
954
1097
  args: {
955
1098
  userId: string;
956
- token: string;
957
- tokenSecret: string;
958
- verifier: string;
1099
+ code: string;
1100
+ codeVerifier: string;
1101
+ redirectUri?: string;
959
1102
  },
960
1103
  ) {
961
1104
  const config = this.requireGarminConfig();
962
1105
  return await ctx.runAction(this.component.garmin.connectGarmin, {
963
1106
  ...args,
964
- consumerKey: config.consumerKey,
965
- consumerSecret: config.consumerSecret,
1107
+ clientId: config.clientId,
1108
+ clientSecret: config.clientSecret,
966
1109
  });
967
1110
  }
968
1111
 
969
1112
  /**
970
- * Complete a Garmin OAuth flow using stored pending state.
1113
+ * Complete a Garmin OAuth 2.0 flow using stored pending state.
971
1114
  *
972
1115
  * 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.
1116
+ * It looks up the pending OAuth state stored during `getGarminAuthUrl`,
1117
+ * exchanges for tokens, creates the connection, and syncs data.
975
1118
  *
976
1119
  * @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
1120
+ * @param args.code - The authorization code from the callback query params
1121
+ * @param args.state - The state parameter from the callback query params
1122
+ * @param args.redirectUri - The redirect URI used in the authorization request
979
1123
  * @returns `{ connectionId, synced, errors }`
980
1124
  */
981
1125
  async completeGarminOAuth(
982
1126
  ctx: ActionCtx,
983
- args: { oauthToken: string; oauthVerifier: string },
1127
+ args: { code: string; state: string; redirectUri?: string },
984
1128
  ) {
985
1129
  const config = this.requireGarminConfig();
986
1130
  return await ctx.runAction(this.component.garmin.completeGarminOAuth, {
987
1131
  ...args,
988
- consumerKey: config.consumerKey,
989
- consumerSecret: config.consumerSecret,
1132
+ clientId: config.clientId,
1133
+ clientSecret: config.clientSecret,
990
1134
  });
991
1135
  }
992
1136
 
@@ -995,6 +1139,7 @@ export class Soma {
995
1139
  *
996
1140
  * Fetches activities, dailies, sleep, body composition, and menstruation
997
1141
  * data for the specified time range (defaults to last 30 days).
1142
+ * Automatically refreshes expired tokens.
998
1143
  *
999
1144
  * @param ctx - Action context from the host app
1000
1145
  * @param args.userId - The host app's user identifier
@@ -1023,17 +1168,16 @@ export class Soma {
1023
1168
  const config = this.requireGarminConfig();
1024
1169
  return await ctx.runAction(this.component.garmin.syncGarmin, {
1025
1170
  ...args,
1026
- consumerKey: config.consumerKey,
1027
- consumerSecret: config.consumerSecret,
1171
+ clientId: config.clientId,
1172
+ clientSecret: config.clientSecret,
1028
1173
  });
1029
1174
  }
1030
1175
 
1031
1176
  /**
1032
1177
  * Disconnect a user from Garmin.
1033
1178
  *
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.
1179
+ * Deregisters the user at Garmin (best-effort), deletes stored tokens,
1180
+ * and sets the connection to inactive.
1037
1181
  *
1038
1182
  * @param ctx - Action context from the host app
1039
1183
  * @param args.userId - The host app's user identifier
@@ -1118,10 +1262,10 @@ export interface StravaRouteOptions {
1118
1262
  export interface GarminRouteOptions {
1119
1263
  /** HTTP path for the OAuth callback. @default "/api/garmin/callback" */
1120
1264
  path?: string;
1121
- /** Override GARMIN_CONSUMER_KEY env var. */
1122
- consumerKey?: string;
1123
- /** Override GARMIN_CONSUMER_SECRET env var. */
1124
- consumerSecret?: string;
1265
+ /** Override GARMIN_CLIENT_ID env var. */
1266
+ clientId?: string;
1267
+ /** Override GARMIN_CLIENT_SECRET env var. */
1268
+ clientSecret?: string;
1125
1269
  /** URL to redirect the user to after a successful connection. */
1126
1270
  onSuccess?: string;
1127
1271
  }
@@ -1256,34 +1400,41 @@ export function registerRoutes(
1256
1400
  method: "GET",
1257
1401
  handler: httpActionGeneric(async (ctx, request) => {
1258
1402
  const url = new URL(request.url);
1259
- const oauthToken = url.searchParams.get("oauth_token");
1260
- const oauthVerifier = url.searchParams.get("oauth_verifier");
1403
+ const code = url.searchParams.get("code");
1404
+ const state = url.searchParams.get("state");
1261
1405
 
1262
- if (!oauthToken || !oauthVerifier) {
1263
- return new Response("Missing oauth_token or oauth_verifier", {
1406
+ if (!code) {
1407
+ return new Response("Missing authorization code", {
1264
1408
  status: 400,
1265
1409
  });
1266
1410
  }
1411
+ if (!state) {
1412
+ return new Response(
1413
+ "Missing state parameter. Ensure the state was included " +
1414
+ "when building the Garmin auth URL.",
1415
+ { status: 400 },
1416
+ );
1417
+ }
1267
1418
 
1268
- const consumerKey =
1269
- garmin.consumerKey ?? process.env.GARMIN_CONSUMER_KEY;
1270
- const consumerSecret =
1271
- garmin.consumerSecret ?? process.env.GARMIN_CONSUMER_SECRET;
1419
+ const clientId =
1420
+ garmin.clientId ?? process.env.GARMIN_CLIENT_ID;
1421
+ const clientSecret =
1422
+ garmin.clientSecret ?? process.env.GARMIN_CLIENT_SECRET;
1272
1423
 
1273
- if (!consumerKey || !consumerSecret) {
1424
+ if (!clientId || !clientSecret) {
1274
1425
  return new Response(
1275
- "Garmin credentials not configured. Set GARMIN_CONSUMER_KEY and " +
1276
- "GARMIN_CONSUMER_SECRET environment variables, or pass them to registerRoutes.",
1426
+ "Garmin credentials not configured. Set GARMIN_CLIENT_ID and " +
1427
+ "GARMIN_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
1277
1428
  { status: 500 },
1278
1429
  );
1279
1430
  }
1280
1431
 
1281
1432
  try {
1282
1433
  await ctx.runAction(component.garmin.completeGarminOAuth, {
1283
- oauthToken,
1284
- oauthVerifier,
1285
- consumerKey,
1286
- consumerSecret,
1434
+ code,
1435
+ state,
1436
+ clientId,
1437
+ clientSecret,
1287
1438
  });
1288
1439
  } catch (error) {
1289
1440
  const message =