@nativesquare/soma 0.4.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.
- package/dist/client/index.d.ts +122 -6
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +184 -6
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +21 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin.d.ts +57 -3
- package/dist/component/garmin.d.ts.map +1 -1
- package/dist/component/garmin.js +159 -4
- package/dist/component/garmin.js.map +1 -1
- package/dist/component/schema.d.ts +15 -0
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +11 -0
- package/dist/component/schema.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +259 -7
- package/src/component/_generated/component.ts +28 -1
- package/src/component/garmin.ts +181 -4
- package/src/component/schema.ts +12 -0
package/src/client/index.ts
CHANGED
|
@@ -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
|
*
|
|
@@ -871,31 +878,41 @@ export class Soma {
|
|
|
871
878
|
* Step 1 of the Garmin OAuth 1.0a flow: obtain a request token.
|
|
872
879
|
*
|
|
873
880
|
* Returns the temporary `token`, `tokenSecret`, and the `authUrl` to
|
|
874
|
-
* redirect the user to.
|
|
875
|
-
*
|
|
876
|
-
*
|
|
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.
|
|
877
890
|
*
|
|
878
891
|
* @param ctx - Action context from the host app
|
|
879
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)
|
|
880
894
|
* @returns `{ token, tokenSecret, authUrl }`
|
|
881
895
|
*
|
|
882
896
|
* @example
|
|
883
897
|
* ```ts
|
|
884
|
-
*
|
|
885
|
-
*
|
|
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",
|
|
886
902
|
* });
|
|
887
|
-
* //
|
|
903
|
+
* // Redirect user to authUrl — the callback is handled automatically
|
|
888
904
|
* ```
|
|
889
905
|
*/
|
|
890
906
|
async getGarminRequestToken(
|
|
891
907
|
ctx: ActionCtx,
|
|
892
|
-
opts: { callbackUrl?: string },
|
|
908
|
+
opts: { callbackUrl?: string; userId?: string },
|
|
893
909
|
) {
|
|
894
910
|
const config = this.requireGarminConfig();
|
|
895
911
|
return await ctx.runAction(this.component.garmin.getGarminRequestToken, {
|
|
896
912
|
consumerKey: config.consumerKey,
|
|
897
913
|
consumerSecret: config.consumerSecret,
|
|
898
914
|
callbackUrl: opts.callbackUrl,
|
|
915
|
+
userId: opts.userId,
|
|
899
916
|
});
|
|
900
917
|
}
|
|
901
918
|
|
|
@@ -949,6 +966,30 @@ export class Soma {
|
|
|
949
966
|
});
|
|
950
967
|
}
|
|
951
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
|
+
|
|
952
993
|
/**
|
|
953
994
|
* Sync all data types from Garmin for an already-connected user.
|
|
954
995
|
*
|
|
@@ -1055,3 +1096,214 @@ type ListTimeRangeArgs = TimeRangeArgs & {
|
|
|
1055
1096
|
type PaginateTimeRangeArgs = TimeRangeArgs & {
|
|
1056
1097
|
paginationOpts: { numItems: number; cursor: string | null };
|
|
1057
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
|
+
}
|
|
@@ -24,6 +24,28 @@ import type { FunctionReference } from "convex/server";
|
|
|
24
24
|
export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
25
25
|
{
|
|
26
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
|
+
>;
|
|
27
49
|
connectGarmin: FunctionReference<
|
|
28
50
|
"action",
|
|
29
51
|
"internal",
|
|
@@ -58,7 +80,12 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|
|
58
80
|
getGarminRequestToken: FunctionReference<
|
|
59
81
|
"action",
|
|
60
82
|
"internal",
|
|
61
|
-
{
|
|
83
|
+
{
|
|
84
|
+
callbackUrl?: string;
|
|
85
|
+
consumerKey: string;
|
|
86
|
+
consumerSecret: string;
|
|
87
|
+
userId?: string;
|
|
88
|
+
},
|
|
62
89
|
{ authUrl: string; token: string; tokenSecret: string },
|
|
63
90
|
Name
|
|
64
91
|
>;
|
package/src/component/garmin.ts
CHANGED
|
@@ -29,6 +29,64 @@ const internalApi: any = anyApi;
|
|
|
29
29
|
// Default sync window: last 30 days
|
|
30
30
|
const DEFAULT_SYNC_DAYS = 30;
|
|
31
31
|
|
|
32
|
+
// ─── Internal Pending OAuth CRUD ─────────────────────────────────────────────
|
|
33
|
+
// Temporary storage for in-progress Garmin OAuth 1.0a flows.
|
|
34
|
+
// Bridges Step 1 (getGarminRequestToken) and Step 3 (completeGarminOAuth).
|
|
35
|
+
|
|
36
|
+
export const storePendingOAuth = internalMutation({
|
|
37
|
+
args: {
|
|
38
|
+
provider: v.string(),
|
|
39
|
+
oauthToken: v.string(),
|
|
40
|
+
tokenSecret: v.string(),
|
|
41
|
+
userId: v.string(),
|
|
42
|
+
},
|
|
43
|
+
returns: v.null(),
|
|
44
|
+
handler: async (ctx, args) => {
|
|
45
|
+
await ctx.db.insert("pendingOAuth", {
|
|
46
|
+
...args,
|
|
47
|
+
createdAt: Date.now(),
|
|
48
|
+
});
|
|
49
|
+
return null;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const getPendingOAuth = internalQuery({
|
|
54
|
+
args: { oauthToken: v.string() },
|
|
55
|
+
returns: v.union(
|
|
56
|
+
v.object({
|
|
57
|
+
_id: v.id("pendingOAuth"),
|
|
58
|
+
_creationTime: v.number(),
|
|
59
|
+
provider: v.string(),
|
|
60
|
+
oauthToken: v.string(),
|
|
61
|
+
tokenSecret: v.string(),
|
|
62
|
+
userId: v.string(),
|
|
63
|
+
createdAt: v.number(),
|
|
64
|
+
}),
|
|
65
|
+
v.null(),
|
|
66
|
+
),
|
|
67
|
+
handler: async (ctx, args) => {
|
|
68
|
+
return await ctx.db
|
|
69
|
+
.query("pendingOAuth")
|
|
70
|
+
.withIndex("by_oauthToken", (q) => q.eq("oauthToken", args.oauthToken))
|
|
71
|
+
.first();
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export const deletePendingOAuth = internalMutation({
|
|
76
|
+
args: { oauthToken: v.string() },
|
|
77
|
+
returns: v.null(),
|
|
78
|
+
handler: async (ctx, args) => {
|
|
79
|
+
const pending = await ctx.db
|
|
80
|
+
.query("pendingOAuth")
|
|
81
|
+
.withIndex("by_oauthToken", (q) => q.eq("oauthToken", args.oauthToken))
|
|
82
|
+
.first();
|
|
83
|
+
if (pending) {
|
|
84
|
+
await ctx.db.delete(pending._id);
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
32
90
|
// ─── Internal Token CRUD ─────────────────────────────────────────────────────
|
|
33
91
|
|
|
34
92
|
/**
|
|
@@ -120,28 +178,42 @@ export const deleteTokens = internalMutation({
|
|
|
120
178
|
/**
|
|
121
179
|
* Step 1 of OAuth: obtain a request token and return the authorization URL.
|
|
122
180
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
181
|
+
* If `userId` is provided, the request token and secret are stored in the
|
|
182
|
+
* component's `pendingOAuth` table so that `completeGarminOAuth` can look
|
|
183
|
+
* them up automatically when the callback fires. This is the recommended
|
|
184
|
+
* flow when using `registerRoutes`.
|
|
185
|
+
*
|
|
186
|
+
* If `userId` is omitted, the host app must store the returned `token`
|
|
187
|
+
* and `tokenSecret` itself and pass them to `connectGarmin` manually.
|
|
126
188
|
*/
|
|
127
189
|
export const getGarminRequestToken = action({
|
|
128
190
|
args: {
|
|
129
191
|
consumerKey: v.string(),
|
|
130
192
|
consumerSecret: v.string(),
|
|
131
193
|
callbackUrl: v.optional(v.string()),
|
|
194
|
+
userId: v.optional(v.string()),
|
|
132
195
|
},
|
|
133
196
|
returns: v.object({
|
|
134
197
|
token: v.string(),
|
|
135
198
|
tokenSecret: v.string(),
|
|
136
199
|
authUrl: v.string(),
|
|
137
200
|
}),
|
|
138
|
-
handler: async (
|
|
201
|
+
handler: async (ctx, args) => {
|
|
139
202
|
const result = await getRequestToken({
|
|
140
203
|
consumerKey: args.consumerKey,
|
|
141
204
|
consumerSecret: args.consumerSecret,
|
|
142
205
|
callbackUrl: args.callbackUrl,
|
|
143
206
|
});
|
|
144
207
|
|
|
208
|
+
if (args.userId) {
|
|
209
|
+
await ctx.runMutation(internalApi.garmin.storePendingOAuth, {
|
|
210
|
+
provider: "GARMIN",
|
|
211
|
+
oauthToken: result.oauthToken,
|
|
212
|
+
tokenSecret: result.oauthTokenSecret,
|
|
213
|
+
userId: args.userId,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
145
217
|
return {
|
|
146
218
|
token: result.oauthToken,
|
|
147
219
|
tokenSecret: result.oauthTokenSecret,
|
|
@@ -239,6 +311,111 @@ export const connectGarmin = action({
|
|
|
239
311
|
},
|
|
240
312
|
});
|
|
241
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Complete a Garmin OAuth flow using stored pending state.
|
|
316
|
+
*
|
|
317
|
+
* Used by `registerRoutes` — the callback handler calls this with the
|
|
318
|
+
* `oauth_token` and `oauth_verifier` from the redirect. The action looks
|
|
319
|
+
* up the pending state (tokenSecret, userId) stored during Step 1,
|
|
320
|
+
* exchanges for permanent tokens, creates the connection, syncs data,
|
|
321
|
+
* and cleans up the pending entry.
|
|
322
|
+
*/
|
|
323
|
+
export const completeGarminOAuth = action({
|
|
324
|
+
args: {
|
|
325
|
+
oauthToken: v.string(),
|
|
326
|
+
oauthVerifier: v.string(),
|
|
327
|
+
consumerKey: v.string(),
|
|
328
|
+
consumerSecret: v.string(),
|
|
329
|
+
},
|
|
330
|
+
returns: v.object({
|
|
331
|
+
connectionId: v.string(),
|
|
332
|
+
synced: v.object({
|
|
333
|
+
activities: v.number(),
|
|
334
|
+
dailies: v.number(),
|
|
335
|
+
sleep: v.number(),
|
|
336
|
+
body: v.number(),
|
|
337
|
+
menstruation: v.number(),
|
|
338
|
+
}),
|
|
339
|
+
errors: v.array(
|
|
340
|
+
v.object({ type: v.string(), id: v.string(), error: v.string() }),
|
|
341
|
+
),
|
|
342
|
+
}),
|
|
343
|
+
handler: async (ctx, args) => {
|
|
344
|
+
// 1. Look up pending state
|
|
345
|
+
const pending = await ctx.runQuery(internalApi.garmin.getPendingOAuth, {
|
|
346
|
+
oauthToken: args.oauthToken,
|
|
347
|
+
});
|
|
348
|
+
if (!pending) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
"No pending Garmin OAuth state found for this token. " +
|
|
351
|
+
"The request token may have expired or was already used.",
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 2. Exchange request token for permanent access token
|
|
356
|
+
const accessTokenResult = await getAccessToken({
|
|
357
|
+
consumerKey: args.consumerKey,
|
|
358
|
+
consumerSecret: args.consumerSecret,
|
|
359
|
+
token: args.oauthToken,
|
|
360
|
+
tokenSecret: pending.tokenSecret,
|
|
361
|
+
verifier: args.oauthVerifier,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// 3. Delete pending state (no longer needed)
|
|
365
|
+
await ctx.runMutation(internalApi.garmin.deletePendingOAuth, {
|
|
366
|
+
oauthToken: args.oauthToken,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// 4. Create/reactivate the Soma connection
|
|
370
|
+
const connectionId = await ctx.runMutation(publicApi.public.connect, {
|
|
371
|
+
userId: pending.userId,
|
|
372
|
+
provider: "GARMIN",
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// 5. Store permanent OAuth tokens
|
|
376
|
+
await ctx.runMutation(internalApi.garmin.storeTokens, {
|
|
377
|
+
connectionId,
|
|
378
|
+
accessToken: accessTokenResult.oauthToken,
|
|
379
|
+
tokenSecret: accessTokenResult.oauthTokenSecret,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// 6. Sync last 30 days of all data types
|
|
383
|
+
const client = new GarminClient({
|
|
384
|
+
consumerKey: args.consumerKey,
|
|
385
|
+
consumerSecret: args.consumerSecret,
|
|
386
|
+
accessToken: accessTokenResult.oauthToken,
|
|
387
|
+
tokenSecret: accessTokenResult.oauthTokenSecret,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const now = Math.floor(Date.now() / 1000);
|
|
391
|
+
const thirtyDaysAgo = now - DEFAULT_SYNC_DAYS * 86400;
|
|
392
|
+
const timeRange = {
|
|
393
|
+
uploadStartTimeInSeconds: thirtyDaysAgo,
|
|
394
|
+
uploadEndTimeInSeconds: now,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const result = await syncAllTypes(ctx, client, {
|
|
398
|
+
connectionId,
|
|
399
|
+
userId: pending.userId,
|
|
400
|
+
consumerKey: args.consumerKey,
|
|
401
|
+
consumerSecret: args.consumerSecret,
|
|
402
|
+
timeRange,
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// 7. Update lastDataUpdate timestamp
|
|
406
|
+
await ctx.runMutation(publicApi.public.updateConnection, {
|
|
407
|
+
connectionId,
|
|
408
|
+
lastDataUpdate: new Date().toISOString(),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
connectionId,
|
|
413
|
+
synced: result.synced,
|
|
414
|
+
errors: result.errors,
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
242
419
|
/**
|
|
243
420
|
* Incremental Garmin sync for an already-connected user.
|
|
244
421
|
*
|
package/src/component/schema.ts
CHANGED
|
@@ -125,4 +125,16 @@ export default defineSchema({
|
|
|
125
125
|
tokenSecret: v.optional(v.string()), // OAuth 1.0a providers (Garmin)
|
|
126
126
|
expiresAt: v.optional(v.number()), // Unix epoch seconds; absent for permanent tokens
|
|
127
127
|
}).index("by_connectionId", ["connectionId"]),
|
|
128
|
+
|
|
129
|
+
// ── Pending OAuth ─────────────────────────────────────────────────────────
|
|
130
|
+
// Temporary storage for in-progress OAuth flows. Bridges the gap between
|
|
131
|
+
// initiating OAuth (Step 1) and the callback (Step 3) for providers like
|
|
132
|
+
// Garmin that use OAuth 1.0a and don't have a `state` parameter.
|
|
133
|
+
pendingOAuth: defineTable({
|
|
134
|
+
provider: v.string(),
|
|
135
|
+
oauthToken: v.string(),
|
|
136
|
+
tokenSecret: v.string(),
|
|
137
|
+
userId: v.string(),
|
|
138
|
+
createdAt: v.number(),
|
|
139
|
+
}).index("by_oauthToken", ["oauthToken"]),
|
|
128
140
|
});
|