@nativesquare/soma 0.10.2 → 0.12.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.
@@ -1,12 +1,44 @@
1
1
  import type { ComponentApi } from "../component/_generated/component.js";
2
- import type { ActionCtx, MutationCtx, QueryCtx } from "./types.js";
2
+ import type {
3
+ MutationCtx,
4
+ QueryCtx,
5
+ SomaStravaConfig,
6
+ SomaGarminConfig,
7
+ IngestArgs,
8
+ ListTimeRangeArgs,
9
+ PaginateTimeRangeArgs,
10
+ RegisterRoutesOptions,
11
+ GarminWebhookEventName,
12
+ GarminWebhookEvent,
13
+ } from "./types.js";
14
+ export type {
15
+ ActionCtx,
16
+ SomaStravaConfig,
17
+ SomaGarminConfig,
18
+ IngestArgs,
19
+ TimeRangeArgs,
20
+ ListTimeRangeArgs,
21
+ PaginateTimeRangeArgs,
22
+ StravaOAuthOptions,
23
+ StravaConnectEvent,
24
+ GarminConnectEvent,
25
+ GarminWebhookEvent,
26
+ GarminOAuthOptions,
27
+ GarminWebhookEventName,
28
+ GarminWebhookHandler,
29
+ GarminWebhookOptions,
30
+ RegisterRoutesOptions,
31
+ } from "./types.js";
3
32
  import {
4
33
  httpActionGeneric,
5
34
  type FunctionReference,
6
- type GenericActionCtx,
7
- type GenericDataModel,
8
35
  type HttpRouter,
9
36
  } from "convex/server";
37
+ import { SomaGarmin } from "./garmin.js";
38
+ import { SomaStrava } from "./strava.js";
39
+
40
+ export { SomaGarmin } from "./garmin.js";
41
+ export { SomaStrava } from "./strava.js";
10
42
 
11
43
  export type SomaComponent = ComponentApi;
12
44
 
@@ -17,34 +49,6 @@ export const STRAVA_CALLBACK_PATH = "/api/strava/callback";
17
49
  export const GARMIN_OAUTH_CALLBACK_PATH = "/api/garmin/callback";
18
50
  export const GARMIN_WEBHOOK_BASE_PATH = "/api/garmin/webhook";
19
51
 
20
- /**
21
- * Configuration for the Strava integration.
22
- *
23
- * If not provided to the constructor, the Soma class will attempt to
24
- * read `STRAVA_CLIENT_ID`, `STRAVA_CLIENT_SECRET`, and `STRAVA_BASE_URL`
25
- * from environment variables automatically.
26
- */
27
- export interface SomaStravaConfig {
28
- /** Your Strava application's Client ID. */
29
- clientId: string;
30
- /** Your Strava application's Client Secret. */
31
- clientSecret: string;
32
- }
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_CLIENT_ID` and `GARMIN_CLIENT_SECRET` from
39
- * environment variables automatically.
40
- */
41
- export interface SomaGarminConfig {
42
- /** Your Garmin application's Client ID. */
43
- clientId: string;
44
- /** Your Garmin application's Client Secret. */
45
- clientSecret: string;
46
- }
47
-
48
52
  /**
49
53
  * Client class for the @nativesquare/soma Convex component.
50
54
  *
@@ -52,7 +56,7 @@ export interface SomaGarminConfig {
52
56
  * and querying normalized health & fitness data.
53
57
  *
54
58
  * All capabilities are also accessible via direct component function calls:
55
- * `ctx.runMutation(components.soma.public.connect, { userId, provider: "GARMIN" })`
59
+ * `ctx.runQuery(components.soma.public.listActivities, { userId: "user_123" })`
56
60
  *
57
61
  * @example
58
62
  * ```ts
@@ -61,37 +65,42 @@ export interface SomaGarminConfig {
61
65
  * import { components } from "./_generated/api";
62
66
  *
63
67
  * // Zero config if env vars are set in Convex dashboard:
64
- * // STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET, STRAVA_BASE_URL (optional)
68
+ * // STRAVA_CLIENT_ID, STRAVA_CLIENT_SECRET
69
+ * // GARMIN_CLIENT_ID, GARMIN_CLIENT_SECRET
65
70
  * const soma = new Soma(components.soma);
66
71
  *
67
- * // Or with explicit Strava config:
72
+ * // Or with explicit config:
68
73
  * // const soma = new Soma(components.soma, {
69
74
  * // strava: { clientId: "...", clientSecret: "..." },
75
+ * // garmin: { clientId: "...", clientSecret: "..." },
70
76
  * // });
71
77
  *
72
- * // Connect a user to a provider:
73
- * const connectionId = await soma.connect(ctx, {
74
- * userId: "user_123",
75
- * provider: "GARMIN",
76
- * });
77
- *
78
78
  * // Start Strava OAuth (redirects user, callback handled by registerRoutes):
79
- * const { authUrl } = await soma.getStravaAuthUrl(ctx, {
79
+ * const { authUrl } = await soma.strava.getAuthUrl(ctx, {
80
80
  * userId: "user_123",
81
81
  * redirectUri: "https://your-app.convex.site/api/strava/callback",
82
82
  * });
83
+ *
84
+ * // Pull all Garmin data:
85
+ * await soma.garmin.pullAll(ctx, { userId: "user_123" });
83
86
  * ```
84
87
  */
85
88
  export class Soma {
86
89
  private stravaConfig?: SomaStravaConfig;
87
90
  private garminConfig?: SomaGarminConfig;
88
91
 
92
+ public readonly garmin: SomaGarmin;
93
+ public readonly strava: SomaStrava;
94
+
89
95
  constructor(
90
96
  public component: SomaComponent,
91
97
  options?: { strava?: SomaStravaConfig; garmin?: SomaGarminConfig },
92
98
  ) {
93
99
  this.stravaConfig = options?.strava ?? this.readStravaEnv();
94
100
  this.garminConfig = options?.garmin ?? this.readGarminEnv();
101
+
102
+ this.garmin = new SomaGarmin(this.component, () => this.requireGarminConfig());
103
+ this.strava = new SomaStrava(this.component, () => this.requireStravaConfig());
95
104
  }
96
105
 
97
106
  /**
@@ -147,66 +156,6 @@ export class Soma {
147
156
  return this.garminConfig;
148
157
  }
149
158
 
150
- // ─── Connect / Disconnect ───────────────────────────────────────────────────
151
-
152
- /**
153
- * Connect a user to a wearable provider.
154
- *
155
- * Creates the connection if it doesn't exist, or re-activates it if it was
156
- * previously disconnected. Idempotent — calling twice is a no-op.
157
- *
158
- * Use this when the host app has completed the provider's auth flow and
159
- * wants to register the connection in Soma.
160
- *
161
- * @param ctx - Mutation context from the host app
162
- * @param args.userId - The host app's user identifier (Clerk ID, etc.)
163
- * @param args.provider - The wearable provider name ("GARMIN", "FITBIT", "OURA", etc.)
164
- * @returns The connection document ID
165
- *
166
- * @example
167
- * ```ts
168
- * // "Connect to Garmin" button handler
169
- * const connectionId = await soma.connect(ctx, {
170
- * userId: "user_123",
171
- * provider: "GARMIN",
172
- * });
173
- * ```
174
- */
175
- async connect(
176
- ctx: MutationCtx,
177
- args: { userId: string; provider: string },
178
- ): Promise<string> {
179
- return await ctx.runMutation(this.component.public.connect, args);
180
- }
181
-
182
- /**
183
- * Disconnect a user from a wearable provider.
184
- *
185
- * Sets the connection to inactive. Does not delete any synced data,
186
- * so re-connecting later preserves historical records.
187
- *
188
- * @param ctx - Mutation context from the host app
189
- * @param args.userId - The host app's user identifier
190
- * @param args.provider - The wearable provider name
191
- *
192
- * @throws Error if no connection exists for the given user–provider pair
193
- *
194
- * @example
195
- * ```ts
196
- * // "Disconnect Garmin" button handler
197
- * await soma.disconnect(ctx, {
198
- * userId: "user_123",
199
- * provider: "GARMIN",
200
- * });
201
- * ```
202
- */
203
- async disconnect(
204
- ctx: MutationCtx,
205
- args: { userId: string; provider: string },
206
- ): Promise<null> {
207
- return await ctx.runMutation(this.component.public.disconnect, args);
208
- }
209
-
210
159
  // ─── Connection Queries ─────────────────────────────────────────────────────
211
160
 
212
161
  /**
@@ -812,7 +761,7 @@ export class Soma {
812
761
  ) {
813
762
  return await ctx.runMutation(
814
763
  this.component.public.deletePlannedWorkout,
815
- args as never,
764
+ args,
816
765
  );
817
766
  }
818
767
 
@@ -828,609 +777,10 @@ export class Soma {
828
777
  ) {
829
778
  return await ctx.runQuery(
830
779
  this.component.public.getPlannedWorkout,
831
- args as never,
780
+ args,
832
781
  );
833
782
  }
834
783
 
835
- /**
836
- * Push a planned workout to Garmin Connect.
837
- *
838
- * Creates the workout on Garmin's Training API and optionally schedules it
839
- * to the user's calendar if `metadata.planned_date` is set. The workout
840
- * will appear on the user's Garmin device after they sync.
841
- *
842
- * @param ctx - Action context from the host app
843
- * @param args.userId - The host app's user identifier
844
- * @param args.plannedWorkoutId - The Soma planned workout document ID
845
- * @param args.workoutProvider - Name shown to user in Garmin Connect (default: "Soma", 20 chars max)
846
- * @returns `{ garminWorkoutId, garminScheduleId }`
847
- *
848
- * @example
849
- * ```ts
850
- * export const pushWorkout = action({
851
- * args: { userId: v.string(), workoutId: v.string() },
852
- * handler: async (ctx, { userId, workoutId }) => {
853
- * return await soma.pushPlannedWorkoutToGarmin(ctx, {
854
- * userId,
855
- * plannedWorkoutId: workoutId,
856
- * });
857
- * },
858
- * });
859
- * ```
860
- */
861
- async pushPlannedWorkoutToGarmin(
862
- ctx: ActionCtx,
863
- args: {
864
- userId: string;
865
- plannedWorkoutId: string;
866
- workoutProvider?: string;
867
- },
868
- ) {
869
- const config = this.requireGarminConfig();
870
- return await ctx.runAction(this.component.garmin.public.pushPlannedWorkout, {
871
- ...args,
872
- clientId: config.clientId,
873
- clientSecret: config.clientSecret,
874
- });
875
- }
876
-
877
- // ─── Strava Integration ──────────────────────────────────────────────────────
878
- // High-level methods that handle OAuth, token storage, and data syncing
879
- // for Strava. Requires Strava credentials to be configured either via
880
- // environment variables or the constructor.
881
-
882
- /**
883
- * Generate a Strava OAuth authorization URL.
884
- *
885
- * The state parameter is stored inside the component automatically,
886
- * and the callback handler registered by `registerRoutes` will
887
- * complete the flow without further host-app intervention.
888
- *
889
- * @param ctx - Action context from the host app
890
- * @param opts.userId - The host app's user identifier
891
- * @param opts.redirectUri - The URL Strava will redirect to after authorization
892
- * @param opts.scope - Comma-separated Strava OAuth scopes (default: "read,activity:read_all,profile:read_all")
893
- * @returns `{ authUrl, state }`
894
- *
895
- * @example
896
- * ```ts
897
- * const { authUrl } = await soma.getStravaAuthUrl(ctx, {
898
- * userId: "user_123",
899
- * redirectUri: "https://your-app.convex.site/api/strava/callback",
900
- * });
901
- * // Redirect user to authUrl — the callback is handled automatically
902
- * ```
903
- */
904
- async getStravaAuthUrl(
905
- ctx: ActionCtx,
906
- opts: { userId: string; redirectUri: string; scope?: string },
907
- ) {
908
- const config = this.requireStravaConfig();
909
- return await ctx.runAction(this.component.strava.public.getStravaAuthUrl, {
910
- clientId: config.clientId,
911
- redirectUri: opts.redirectUri,
912
- scope: opts.scope,
913
- userId: opts.userId,
914
- });
915
- }
916
-
917
-
918
- /**
919
- * Sync activities from Strava for an already-connected user.
920
- *
921
- * Automatically refreshes the access token if expired. Fetches the
922
- * athlete profile and activities, transforms them, and ingests into Soma.
923
- *
924
- * @param ctx - Action context from the host app
925
- * @param args.userId - The host app's user identifier
926
- * @param args.after - Only sync activities after this Unix epoch timestamp (for incremental sync)
927
- * @returns `{ synced, errors }`
928
- *
929
- * @example
930
- * ```ts
931
- * export const syncStrava = action({
932
- * args: { userId: v.string() },
933
- * handler: async (ctx, { userId }) => {
934
- * return await soma.syncStrava(ctx, { userId });
935
- * },
936
- * });
937
- * ```
938
- */
939
- async syncStrava(
940
- ctx: ActionCtx,
941
- args: { userId: string; after?: number },
942
- ) {
943
- const config = this.requireStravaConfig();
944
- return await ctx.runAction(this.component.strava.public.syncStrava, {
945
- ...args,
946
- clientId: config.clientId,
947
- clientSecret: config.clientSecret,
948
- });
949
- }
950
-
951
- /**
952
- * Disconnect a user from Strava.
953
- *
954
- * Revokes the token at Strava (best-effort), deletes stored tokens,
955
- * and sets the connection to inactive.
956
- *
957
- * @param ctx - Action context from the host app
958
- * @param args.userId - The host app's user identifier
959
- *
960
- * @example
961
- * ```ts
962
- * export const disconnectStrava = action({
963
- * args: { userId: v.string() },
964
- * handler: async (ctx, { userId }) => {
965
- * await soma.disconnectStrava(ctx, { userId });
966
- * },
967
- * });
968
- * ```
969
- */
970
- async disconnectStrava(
971
- ctx: ActionCtx,
972
- args: { userId: string },
973
- ) {
974
- const config = this.requireStravaConfig();
975
- return await ctx.runAction(this.component.strava.public.disconnectStrava, {
976
- ...args,
977
- clientId: config.clientId,
978
- clientSecret: config.clientSecret,
979
- });
980
- }
981
-
982
- // ─── Garmin Integration ──────────────────────────────────────────────────────
983
- // High-level methods that handle OAuth 2.0 PKCE, token storage, and data
984
- // syncing for Garmin. Requires Garmin credentials to be configured either
985
- // via environment variables or the constructor.
986
-
987
- /**
988
- * Generate a Garmin OAuth 2.0 authorization URL with PKCE.
989
- *
990
- * The PKCE state is stored inside the component automatically,
991
- * and the callback handler registered by `registerRoutes` will
992
- * complete the flow without further host-app intervention.
993
- *
994
- * @param ctx - Action context from the host app
995
- * @param opts.userId - The host app's user identifier
996
- * @param opts.redirectUri - The URL Garmin will redirect to after authorization
997
- * @returns `{ authUrl, state, codeVerifier }`
998
- *
999
- * @example
1000
- * ```ts
1001
- * const { authUrl } = await soma.getGarminAuthUrl(ctx, {
1002
- * userId: "user_123",
1003
- * redirectUri: "https://your-app.convex.site/api/garmin/callback",
1004
- * });
1005
- * // Redirect user to authUrl — the callback is handled automatically
1006
- * ```
1007
- */
1008
- async getGarminAuthUrl(
1009
- ctx: ActionCtx,
1010
- opts: { userId: string; redirectUri?: string },
1011
- ) {
1012
- const config = this.requireGarminConfig();
1013
- return await ctx.runAction(this.component.garmin.public.getGarminAuthUrl, {
1014
- clientId: config.clientId,
1015
- redirectUri: opts.redirectUri,
1016
- userId: opts.userId,
1017
- });
1018
- }
1019
-
1020
- async pullGarminActivities(
1021
- ctx: ActionCtx,
1022
- args: {
1023
- userId: string;
1024
- startTimeInSeconds?: number;
1025
- endTimeInSeconds?: number;
1026
- },
1027
- ) {
1028
- const config = this.requireGarminConfig();
1029
- return await ctx.runAction(this.component.garmin.public.pullActivities, {
1030
- ...args,
1031
- clientId: config.clientId,
1032
- clientSecret: config.clientSecret,
1033
- });
1034
- }
1035
-
1036
- async pullGarminDailies(
1037
- ctx: ActionCtx,
1038
- args: {
1039
- userId: string;
1040
- startTimeInSeconds?: number;
1041
- endTimeInSeconds?: number;
1042
- },
1043
- ) {
1044
- const config = this.requireGarminConfig();
1045
- return await ctx.runAction(this.component.garmin.public.pullDailies, {
1046
- ...args,
1047
- clientId: config.clientId,
1048
- clientSecret: config.clientSecret,
1049
- });
1050
- }
1051
-
1052
- async pullGarminSleep(
1053
- ctx: ActionCtx,
1054
- args: {
1055
- userId: string;
1056
- startTimeInSeconds?: number;
1057
- endTimeInSeconds?: number;
1058
- },
1059
- ) {
1060
- const config = this.requireGarminConfig();
1061
- return await ctx.runAction(this.component.garmin.public.pullSleep, {
1062
- ...args,
1063
- clientId: config.clientId,
1064
- clientSecret: config.clientSecret,
1065
- });
1066
- }
1067
-
1068
- async pullGarminBody(
1069
- ctx: ActionCtx,
1070
- args: {
1071
- userId: string;
1072
- startTimeInSeconds?: number;
1073
- endTimeInSeconds?: number;
1074
- },
1075
- ) {
1076
- const config = this.requireGarminConfig();
1077
- return await ctx.runAction(this.component.garmin.public.pullBody, {
1078
- ...args,
1079
- clientId: config.clientId,
1080
- clientSecret: config.clientSecret,
1081
- });
1082
- }
1083
-
1084
- async pullGarminMenstruation(
1085
- ctx: ActionCtx,
1086
- args: {
1087
- userId: string;
1088
- startTimeInSeconds?: number;
1089
- endTimeInSeconds?: number;
1090
- },
1091
- ) {
1092
- const config = this.requireGarminConfig();
1093
- return await ctx.runAction(this.component.garmin.public.pullMenstruation, {
1094
- ...args,
1095
- clientId: config.clientId,
1096
- clientSecret: config.clientSecret,
1097
- });
1098
- }
1099
-
1100
- async pullGarminBloodPressures(
1101
- ctx: ActionCtx,
1102
- args: {
1103
- userId: string;
1104
- startTimeInSeconds?: number;
1105
- endTimeInSeconds?: number;
1106
- },
1107
- ) {
1108
- const config = this.requireGarminConfig();
1109
- return await ctx.runAction(this.component.garmin.public.pullBloodPressures, {
1110
- ...args,
1111
- clientId: config.clientId,
1112
- clientSecret: config.clientSecret,
1113
- });
1114
- }
1115
-
1116
- async pullGarminSkinTemperature(
1117
- ctx: ActionCtx,
1118
- args: {
1119
- userId: string;
1120
- startTimeInSeconds?: number;
1121
- endTimeInSeconds?: number;
1122
- },
1123
- ) {
1124
- const config = this.requireGarminConfig();
1125
- return await ctx.runAction(this.component.garmin.public.pullSkinTemperature, {
1126
- ...args,
1127
- clientId: config.clientId,
1128
- clientSecret: config.clientSecret,
1129
- });
1130
- }
1131
-
1132
- async pullGarminUserMetrics(
1133
- ctx: ActionCtx,
1134
- args: {
1135
- userId: string;
1136
- startTimeInSeconds?: number;
1137
- endTimeInSeconds?: number;
1138
- },
1139
- ) {
1140
- const config = this.requireGarminConfig();
1141
- return await ctx.runAction(this.component.garmin.public.pullUserMetrics, {
1142
- ...args,
1143
- clientId: config.clientId,
1144
- clientSecret: config.clientSecret,
1145
- });
1146
- }
1147
-
1148
- async pullGarminHRV(
1149
- ctx: ActionCtx,
1150
- args: {
1151
- userId: string;
1152
- startTimeInSeconds?: number;
1153
- endTimeInSeconds?: number;
1154
- },
1155
- ) {
1156
- const config = this.requireGarminConfig();
1157
- return await ctx.runAction(this.component.garmin.public.pullHRV, {
1158
- ...args,
1159
- clientId: config.clientId,
1160
- clientSecret: config.clientSecret,
1161
- });
1162
- }
1163
-
1164
- async pullGarminStressDetails(
1165
- ctx: ActionCtx,
1166
- args: {
1167
- userId: string;
1168
- startTimeInSeconds?: number;
1169
- endTimeInSeconds?: number;
1170
- },
1171
- ) {
1172
- const config = this.requireGarminConfig();
1173
- return await ctx.runAction(this.component.garmin.public.pullStressDetails, {
1174
- ...args,
1175
- clientId: config.clientId,
1176
- clientSecret: config.clientSecret,
1177
- });
1178
- }
1179
-
1180
- async pullGarminPulseOx(
1181
- ctx: ActionCtx,
1182
- args: {
1183
- userId: string;
1184
- startTimeInSeconds?: number;
1185
- endTimeInSeconds?: number;
1186
- },
1187
- ) {
1188
- const config = this.requireGarminConfig();
1189
- return await ctx.runAction(this.component.garmin.public.pullPulseOx, {
1190
- ...args,
1191
- clientId: config.clientId,
1192
- clientSecret: config.clientSecret,
1193
- });
1194
- }
1195
-
1196
- async pullGarminRespiration(
1197
- ctx: ActionCtx,
1198
- args: {
1199
- userId: string;
1200
- startTimeInSeconds?: number;
1201
- endTimeInSeconds?: number;
1202
- },
1203
- ) {
1204
- const config = this.requireGarminConfig();
1205
- return await ctx.runAction(this.component.garmin.public.pullRespiration, {
1206
- ...args,
1207
- clientId: config.clientId,
1208
- clientSecret: config.clientSecret,
1209
- });
1210
- }
1211
-
1212
- async pullGarminAll(
1213
- ctx: ActionCtx,
1214
- args: {
1215
- userId: string;
1216
- startTimeInSeconds?: number;
1217
- endTimeInSeconds?: number;
1218
- },
1219
- ) {
1220
- const config = this.requireGarminConfig();
1221
- return await ctx.runAction(this.component.garmin.public.pullAll, {
1222
- ...args,
1223
- clientId: config.clientId,
1224
- clientSecret: config.clientSecret,
1225
- });
1226
- }
1227
-
1228
- /**
1229
- * Sync all data types from Garmin for an already-connected user.
1230
- *
1231
- * Fetches activities, dailies, sleep, body composition, and menstruation
1232
- * data for the specified time range (defaults to last 30 days).
1233
- * Automatically refreshes expired tokens.
1234
- *
1235
- * @param ctx - Action context from the host app
1236
- * @param args.userId - The host app's user identifier
1237
- * @param args.startTimeInSeconds - Optional start of time range (Unix epoch seconds)
1238
- * @param args.endTimeInSeconds - Optional end of time range (Unix epoch seconds)
1239
- * @returns `{ synced, errors }`
1240
- *
1241
- * @example
1242
- * ```ts
1243
- * export const syncGarmin = action({
1244
- * args: { userId: v.string() },
1245
- * handler: async (ctx, { userId }) => {
1246
- * return await soma.syncGarmin(ctx, { userId });
1247
- * },
1248
- * });
1249
- * ```
1250
- */
1251
- async syncGarmin(
1252
- ctx: ActionCtx,
1253
- args: {
1254
- userId: string;
1255
- startTimeInSeconds?: number;
1256
- endTimeInSeconds?: number;
1257
- },
1258
- ) {
1259
- const config = this.requireGarminConfig();
1260
- return await ctx.runAction(this.component.garmin.public.syncGarmin, {
1261
- ...args,
1262
- clientId: config.clientId,
1263
- clientSecret: config.clientSecret,
1264
- });
1265
- }
1266
-
1267
- /**
1268
- * Disconnect a user from Garmin.
1269
- *
1270
- * Deregisters the user at Garmin (best-effort), deletes stored tokens,
1271
- * and sets the connection to inactive.
1272
- *
1273
- * @param ctx - Action context from the host app
1274
- * @param args.userId - The host app's user identifier
1275
- *
1276
- * @example
1277
- * ```ts
1278
- * export const disconnectGarmin = action({
1279
- * args: { userId: v.string() },
1280
- * handler: async (ctx, { userId }) => {
1281
- * await soma.disconnectGarmin(ctx, { userId });
1282
- * },
1283
- * });
1284
- * ```
1285
- */
1286
- async disconnectGarmin(
1287
- ctx: ActionCtx,
1288
- args: { userId: string },
1289
- ) {
1290
- return await ctx.runAction(this.component.garmin.public.disconnectGarmin, args);
1291
- }
1292
- }
1293
-
1294
- // ─── Shared Types ────────────────────────────────────────────────────────────
1295
-
1296
- /**
1297
- * Common args shape for all ingestion methods.
1298
- *
1299
- * Requires `connectionId` and `userId` at minimum — additional fields
1300
- * come from the transformer output (e.g., `metadata`, `calories_data`, etc.)
1301
- * and are validated server-side by Convex validators.
1302
- */
1303
- type IngestArgs = {
1304
- connectionId: string;
1305
- userId: string;
1306
- } & Record<string, unknown>;
1307
-
1308
- /**
1309
- * Base args for time-range filtered queries.
1310
- *
1311
- * - `userId` is required for all health data queries.
1312
- * - `startTime` / `endTime` are optional ISO-8601 bounds on `metadata.start_time`.
1313
- */
1314
- type TimeRangeArgs = {
1315
- userId: string;
1316
- startTime?: string;
1317
- endTime?: string;
1318
- };
1319
-
1320
- /**
1321
- * Args for list (collect-all) queries with optional ordering and limit.
1322
- */
1323
- type ListTimeRangeArgs = TimeRangeArgs & {
1324
- order?: "asc" | "desc";
1325
- limit?: number;
1326
- };
1327
-
1328
- /**
1329
- * Args for paginated queries with Convex pagination options.
1330
- */
1331
- type PaginateTimeRangeArgs = TimeRangeArgs & {
1332
- paginationOpts: { numItems: number; cursor: string | null };
1333
- };
1334
-
1335
- // ─── Route Registration ──────────────────────────────────────────────────────
1336
-
1337
- /**
1338
- * Per-provider options for `registerRoutes`.
1339
- */
1340
- export interface StravaOAuthOptions {
1341
- /** HTTP path for the OAuth callback. @default "/api/strava/callback" */
1342
- path?: string;
1343
- /** Override STRAVA_CLIENT_ID env var. */
1344
- clientId?: string;
1345
- /** Override STRAVA_CLIENT_SECRET env var. */
1346
- clientSecret?: string;
1347
- /** URL to redirect the user to after a successful connection. */
1348
- redirectTo?: string;
1349
- /** Called after Strava OAuth completes and the connection is established. */
1350
- onComplete?: (
1351
- ctx: GenericActionCtx<GenericDataModel>,
1352
- event: StravaConnectEvent,
1353
- ) => Promise<void>;
1354
- }
1355
-
1356
- // ─── Strava Callback Event Types ────────────────────────────────────────────
1357
-
1358
- /** Data passed to `onComplete` after Strava OAuth completes. */
1359
- export interface StravaConnectEvent {
1360
- provider: "STRAVA";
1361
- userId: string;
1362
- connectionId: string;
1363
- }
1364
-
1365
- // ─── Garmin Callback Event Types ─────────────────────────────────────────────
1366
-
1367
- /** Data passed to `oauth.onComplete` after Garmin OAuth completes. */
1368
- export interface GarminConnectEvent {
1369
- provider: "GARMIN";
1370
- userId: string;
1371
- connectionId: string;
1372
- }
1373
-
1374
- /** Data passed to webhook `events` handlers and `onEvent` after data ingestion. */
1375
- export interface GarminWebhookEvent {
1376
- dataType: string;
1377
- processed: number;
1378
- errors: Array<{ type: string; id: string; error: string }>;
1379
- /** Users whose data was affected by this webhook. */
1380
- affectedUsers: Array<{ userId: string; connectionId: string }>;
1381
- }
1382
-
1383
- // ─── Garmin Route Options ───────────────────────────────────────────────────
1384
-
1385
- export interface GarminOAuthOptions {
1386
- /** HTTP path for the OAuth callback. @default "/api/garmin/callback" */
1387
- path?: string;
1388
- /** Override GARMIN_CLIENT_ID env var. */
1389
- clientId?: string;
1390
- /** Override GARMIN_CLIENT_SECRET env var. */
1391
- clientSecret?: string;
1392
- /** URL to redirect the user to after a successful connection. */
1393
- redirectTo?: string;
1394
- /** Called after Garmin OAuth completes and the connection is established. */
1395
- onComplete?: (
1396
- ctx: GenericActionCtx<GenericDataModel>,
1397
- event: GarminConnectEvent,
1398
- ) => Promise<void>;
1399
- }
1400
-
1401
- /** Webhook endpoint names matching the Garmin API data types. */
1402
- export type GarminWebhookEventName =
1403
- | "activities" | "activity-details" | "manually-updated-activities" | "move-iq"
1404
- | "blood-pressures" | "body-compositions" | "dailies" | "epochs"
1405
- | "health-snapshot" | "sleeps" | "hrv" | "stress" | "pulse-ox"
1406
- | "respiration" | "skin-temp" | "user-metrics" | "menstrual-cycle-tracking";
1407
-
1408
- /** Handler for a specific webhook event or the catch-all `onEvent`. */
1409
- export type GarminWebhookHandler = (
1410
- ctx: GenericActionCtx<GenericDataModel>,
1411
- event: GarminWebhookEvent,
1412
- ) => Promise<void>;
1413
-
1414
- export interface GarminWebhookOptions {
1415
- /** Base path prefix for all webhook routes. @default "/api/garmin/webhook" */
1416
- basePath?: string;
1417
- /** Called after every webhook payload is processed, regardless of data type. */
1418
- onEvent?: GarminWebhookHandler;
1419
- /** Per-data-type handlers. Only subscribe to the types you care about. */
1420
- events?: Partial<Record<GarminWebhookEventName, GarminWebhookHandler>>;
1421
- }
1422
-
1423
- export interface RegisterRoutesOptions {
1424
- strava?: {
1425
- /** OAuth callback configuration. */
1426
- oauth?: StravaOAuthOptions;
1427
- };
1428
- garmin?: {
1429
- /** OAuth callback configuration. */
1430
- oauth?: GarminOAuthOptions;
1431
- /** Webhook route configuration. Set to `false` to skip webhook registration. */
1432
- webhook?: GarminWebhookOptions | false;
1433
- };
1434
784
  }
1435
785
 
1436
786
  /**
@@ -1490,14 +840,18 @@ export interface RegisterRoutesOptions {
1490
840
  * },
1491
841
  * },
1492
842
  * webhook: {
1493
- * onEvent: async (ctx, event) => {
1494
- * // Catch-all: runs for every webhook
1495
- * console.log(`Garmin ${event.dataType}: ${event.processed} processed`);
1496
- * },
843
+ * // Only listed data types get an HTTP route registered.
844
+ * // Pass a handler for custom logic, or `true` for default processing.
1497
845
  * events: {
1498
846
  * "activities": async (ctx, event) => {
1499
- * // Runs only for activity webhooks
847
+ * // Custom side-effect after activity ingestion
1500
848
  * },
849
+ * "sleeps": true, // register route, default processing only
850
+ * "dailies": true,
851
+ * },
852
+ * // Optional catch-all, runs for every registered event
853
+ * onEvent: async (ctx, event) => {
854
+ * console.log(`Garmin ${event.dataType}: ${event.processed} processed`);
1501
855
  * },
1502
856
  * },
1503
857
  * },
@@ -1683,8 +1037,8 @@ export function registerRoutes(
1683
1037
  });
1684
1038
 
1685
1039
  // ── Garmin Webhook Routes ──────────────────────────────────
1686
- if (garminOpts.webhook !== false) {
1687
- const webhookCfg = typeof garminOpts.webhook === "object" ? garminOpts.webhook : {};
1040
+ const webhookCfg = typeof garminOpts.webhook === "object" ? garminOpts.webhook : undefined;
1041
+ if (webhookCfg?.events) {
1688
1042
  const webhookBase = webhookCfg.basePath ?? GARMIN_WEBHOOK_BASE_PATH;
1689
1043
 
1690
1044
  const webhookRoutes: Array<{
@@ -1715,6 +1069,11 @@ export function registerRoutes(
1715
1069
  ];
1716
1070
 
1717
1071
  for (const route of webhookRoutes) {
1072
+ const dataType = route.suffix.slice(1) as GarminWebhookEventName;
1073
+
1074
+ // Only register routes the host app explicitly opted into
1075
+ if (!webhookCfg.events?.[dataType]) continue;
1076
+
1718
1077
  http.route({
1719
1078
  path: `${webhookBase}${route.suffix}`,
1720
1079
  method: "POST",
@@ -1738,7 +1097,6 @@ export function registerRoutes(
1738
1097
  }
1739
1098
 
1740
1099
  if (result) {
1741
- const dataType = route.suffix.slice(1) as GarminWebhookEventName;
1742
1100
  const event: GarminWebhookEvent = {
1743
1101
  dataType,
1744
1102
  processed: result.processed,
@@ -1747,7 +1105,7 @@ export function registerRoutes(
1747
1105
  };
1748
1106
 
1749
1107
  const specificHandler = webhookCfg.events?.[dataType];
1750
- if (specificHandler) {
1108
+ if (typeof specificHandler === "function") {
1751
1109
  try {
1752
1110
  await specificHandler(ctx, event);
1753
1111
  } catch (callbackError) {