@nativesquare/soma 0.12.0 → 0.13.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/garmin.d.ts +5 -1
- package/dist/client/garmin.d.ts.map +1 -1
- package/dist/client/garmin.js +148 -0
- package/dist/client/garmin.js.map +1 -1
- package/dist/client/index.d.ts +5 -6
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +5 -211
- package/dist/client/index.js.map +1 -1
- package/dist/client/strava.d.ts +11 -6
- package/dist/client/strava.d.ts.map +1 -1
- package/dist/client/strava.js +64 -0
- package/dist/client/strava.js.map +1 -1
- package/dist/client/types.d.ts +93 -20
- package/dist/client/types.d.ts.map +1 -1
- package/dist/component/_generated/component.d.ts +24 -5
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/garmin/private.d.ts +53 -68
- package/dist/component/garmin/private.d.ts.map +1 -1
- package/dist/component/garmin/private.js +87 -85
- package/dist/component/garmin/private.js.map +1 -1
- package/dist/component/garmin/public.d.ts +97 -43
- package/dist/component/garmin/public.d.ts.map +1 -1
- package/dist/component/garmin/public.js +75 -51
- package/dist/component/garmin/public.js.map +1 -1
- package/dist/component/garmin/webhooks.d.ts +22 -20
- package/dist/component/garmin/webhooks.d.ts.map +1 -1
- package/dist/component/garmin/webhooks.js +115 -76
- package/dist/component/garmin/webhooks.js.map +1 -1
- package/dist/component/public.d.ts +15 -15
- package/dist/component/schema.d.ts +25 -25
- package/dist/component/strava/public.d.ts +12 -8
- package/dist/component/strava/public.d.ts.map +1 -1
- package/dist/component/strava/public.js +7 -7
- package/dist/component/strava/public.js.map +1 -1
- package/dist/component/validators/activity.d.ts +4 -4
- package/dist/component/validators/body.d.ts +4 -4
- package/dist/component/validators/daily.d.ts +4 -4
- package/dist/component/validators/nutrition.d.ts +3 -3
- package/dist/component/validators/samples.d.ts +4 -4
- package/dist/component/validators/shared.d.ts +13 -4
- package/dist/component/validators/shared.d.ts.map +1 -1
- package/dist/component/validators/shared.js +7 -0
- package/dist/component/validators/shared.js.map +1 -1
- package/dist/component/validators/sleep.d.ts +5 -5
- package/dist/validators.d.ts +41 -40
- package/dist/validators.d.ts.map +1 -1
- package/dist/validators.js +1 -0
- package/dist/validators.js.map +1 -1
- package/package.json +1 -1
- package/src/client/garmin.ts +692 -487
- package/src/client/index.ts +10 -279
- package/src/client/strava.ts +199 -108
- package/src/client/types.ts +303 -215
- package/src/component/_generated/component.ts +19 -19
- package/src/component/garmin/private.ts +1872 -1870
- package/src/component/garmin/public.ts +104 -80
- package/src/component/garmin/webhooks.ts +122 -81
- package/src/component/strava/public.ts +393 -393
- package/src/component/validators/shared.ts +9 -0
- package/src/validators.ts +1 -0
package/src/client/index.ts
CHANGED
|
@@ -8,11 +8,11 @@ import type {
|
|
|
8
8
|
ListTimeRangeArgs,
|
|
9
9
|
PaginateTimeRangeArgs,
|
|
10
10
|
RegisterRoutesOptions,
|
|
11
|
-
GarminWebhookEventName,
|
|
12
|
-
GarminWebhookEvent,
|
|
13
11
|
} from "./types.js";
|
|
14
12
|
export type {
|
|
15
13
|
ActionCtx,
|
|
14
|
+
SomaError,
|
|
15
|
+
SomaResult,
|
|
16
16
|
SomaStravaConfig,
|
|
17
17
|
SomaGarminConfig,
|
|
18
18
|
IngestArgs,
|
|
@@ -22,6 +22,8 @@ export type {
|
|
|
22
22
|
StravaOAuthOptions,
|
|
23
23
|
StravaConnectEvent,
|
|
24
24
|
GarminConnectEvent,
|
|
25
|
+
GarminWebhookActionArgs,
|
|
26
|
+
GarminWebhookActionResult,
|
|
25
27
|
GarminWebhookEvent,
|
|
26
28
|
GarminOAuthOptions,
|
|
27
29
|
GarminWebhookEventName,
|
|
@@ -29,26 +31,17 @@ export type {
|
|
|
29
31
|
GarminWebhookOptions,
|
|
30
32
|
RegisterRoutesOptions,
|
|
31
33
|
} from "./types.js";
|
|
32
|
-
import {
|
|
33
|
-
httpActionGeneric,
|
|
34
|
-
type FunctionReference,
|
|
35
|
-
type HttpRouter,
|
|
36
|
-
} from "convex/server";
|
|
34
|
+
import type { HttpRouter } from "convex/server";
|
|
37
35
|
import { SomaGarmin } from "./garmin.js";
|
|
38
36
|
import { SomaStrava } from "./strava.js";
|
|
39
37
|
|
|
40
38
|
export { SomaGarmin } from "./garmin.js";
|
|
41
39
|
export { SomaStrava } from "./strava.js";
|
|
40
|
+
export { STRAVA_CALLBACK_PATH } from "./strava.js";
|
|
41
|
+
export { GARMIN_OAUTH_CALLBACK_PATH, GARMIN_WEBHOOK_BASE_PATH } from "./garmin.js";
|
|
42
42
|
|
|
43
43
|
export type SomaComponent = ComponentApi;
|
|
44
44
|
|
|
45
|
-
// ─── Default OAuth Callback Paths ────────────────────────────────────────────
|
|
46
|
-
// Used by `registerRoutes` as defaults. Override per-provider in the opts.
|
|
47
|
-
|
|
48
|
-
export const STRAVA_CALLBACK_PATH = "/api/strava/callback";
|
|
49
|
-
export const GARMIN_OAUTH_CALLBACK_PATH = "/api/garmin/callback";
|
|
50
|
-
export const GARMIN_WEBHOOK_BASE_PATH = "/api/garmin/webhook";
|
|
51
|
-
|
|
52
45
|
/**
|
|
53
46
|
* Client class for the @nativesquare/soma Convex component.
|
|
54
47
|
*
|
|
@@ -851,7 +844,7 @@ export class Soma {
|
|
|
851
844
|
* },
|
|
852
845
|
* // Optional catch-all, runs for every registered event
|
|
853
846
|
* onEvent: async (ctx, event) => {
|
|
854
|
-
* console.log(`Garmin ${event.dataType}: ${event.
|
|
847
|
+
* console.log(`Garmin ${event.dataType}: ${event.items.length} items`);
|
|
855
848
|
* },
|
|
856
849
|
* },
|
|
857
850
|
* },
|
|
@@ -866,272 +859,10 @@ export function registerRoutes(
|
|
|
866
859
|
const registerAll = opts === undefined;
|
|
867
860
|
|
|
868
861
|
if (registerAll || opts?.strava) {
|
|
869
|
-
|
|
870
|
-
const oauth = stravaOpts.oauth ?? {};
|
|
871
|
-
const path = oauth.path ?? STRAVA_CALLBACK_PATH;
|
|
872
|
-
|
|
873
|
-
http.route({
|
|
874
|
-
path,
|
|
875
|
-
method: "GET",
|
|
876
|
-
handler: httpActionGeneric(async (ctx, request) => {
|
|
877
|
-
const url = new URL(request.url);
|
|
878
|
-
const code = url.searchParams.get("code");
|
|
879
|
-
const state = url.searchParams.get("state");
|
|
880
|
-
|
|
881
|
-
if (!code) {
|
|
882
|
-
return new Response("Missing authorization code", { status: 400 });
|
|
883
|
-
}
|
|
884
|
-
if (!state) {
|
|
885
|
-
return new Response(
|
|
886
|
-
"Missing state parameter. Ensure the state was included " +
|
|
887
|
-
"when building the Strava auth URL via getStravaAuthUrl.",
|
|
888
|
-
{ status: 400 },
|
|
889
|
-
);
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
const clientId =
|
|
893
|
-
oauth.clientId ?? process.env.STRAVA_CLIENT_ID;
|
|
894
|
-
const clientSecret =
|
|
895
|
-
oauth.clientSecret ?? process.env.STRAVA_CLIENT_SECRET;
|
|
896
|
-
|
|
897
|
-
if (!clientId || !clientSecret) {
|
|
898
|
-
return new Response(
|
|
899
|
-
"Strava credentials not configured. Set STRAVA_CLIENT_ID and " +
|
|
900
|
-
"STRAVA_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
|
|
901
|
-
{ status: 500 },
|
|
902
|
-
);
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
let result: {
|
|
906
|
-
connectionId: string;
|
|
907
|
-
userId: string;
|
|
908
|
-
};
|
|
909
|
-
try {
|
|
910
|
-
result = await ctx.runAction(component.strava.public.completeStravaOAuth, {
|
|
911
|
-
code,
|
|
912
|
-
state,
|
|
913
|
-
clientId,
|
|
914
|
-
clientSecret,
|
|
915
|
-
});
|
|
916
|
-
} catch (error) {
|
|
917
|
-
const message =
|
|
918
|
-
error instanceof Error ? error.message : "Unknown error";
|
|
919
|
-
return new Response(`Strava OAuth callback failed: ${message}`, {
|
|
920
|
-
status: 500,
|
|
921
|
-
});
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
if (oauth.onComplete) {
|
|
925
|
-
try {
|
|
926
|
-
await oauth.onComplete(ctx, {
|
|
927
|
-
provider: "STRAVA",
|
|
928
|
-
userId: result.userId,
|
|
929
|
-
connectionId: result.connectionId,
|
|
930
|
-
});
|
|
931
|
-
} catch (callbackError) {
|
|
932
|
-
console.error(
|
|
933
|
-
"[soma] strava onComplete callback error:",
|
|
934
|
-
callbackError instanceof Error ? callbackError.message : callbackError,
|
|
935
|
-
);
|
|
936
|
-
}
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
if (oauth.redirectTo) {
|
|
940
|
-
return new Response(null, {
|
|
941
|
-
status: 302,
|
|
942
|
-
headers: { Location: oauth.redirectTo },
|
|
943
|
-
});
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
return new Response("Successfully connected to Strava!", {
|
|
947
|
-
status: 200,
|
|
948
|
-
});
|
|
949
|
-
}),
|
|
950
|
-
});
|
|
862
|
+
SomaStrava.registerRoutes(http, component, opts?.strava);
|
|
951
863
|
}
|
|
952
864
|
|
|
953
865
|
if (registerAll || opts?.garmin) {
|
|
954
|
-
|
|
955
|
-
const oauth = garminOpts.oauth ?? {};
|
|
956
|
-
const path = oauth.path ?? GARMIN_OAUTH_CALLBACK_PATH;
|
|
957
|
-
|
|
958
|
-
http.route({
|
|
959
|
-
path,
|
|
960
|
-
method: "GET",
|
|
961
|
-
handler: httpActionGeneric(async (ctx, request) => {
|
|
962
|
-
const url = new URL(request.url);
|
|
963
|
-
const code = url.searchParams.get("code");
|
|
964
|
-
const state = url.searchParams.get("state");
|
|
965
|
-
|
|
966
|
-
if (!code) {
|
|
967
|
-
return new Response("Missing authorization code", {
|
|
968
|
-
status: 400,
|
|
969
|
-
});
|
|
970
|
-
}
|
|
971
|
-
if (!state) {
|
|
972
|
-
return new Response(
|
|
973
|
-
"Missing state parameter. Ensure the state was included " +
|
|
974
|
-
"when building the Garmin auth URL.",
|
|
975
|
-
{ status: 400 },
|
|
976
|
-
);
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
const clientId =
|
|
980
|
-
oauth.clientId ?? process.env.GARMIN_CLIENT_ID;
|
|
981
|
-
const clientSecret =
|
|
982
|
-
oauth.clientSecret ?? process.env.GARMIN_CLIENT_SECRET;
|
|
983
|
-
|
|
984
|
-
if (!clientId || !clientSecret) {
|
|
985
|
-
return new Response(
|
|
986
|
-
"Garmin credentials not configured. Set GARMIN_CLIENT_ID and " +
|
|
987
|
-
"GARMIN_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
|
|
988
|
-
{ status: 500 },
|
|
989
|
-
);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
let result: {
|
|
993
|
-
connectionId: string;
|
|
994
|
-
userId: string;
|
|
995
|
-
};
|
|
996
|
-
try {
|
|
997
|
-
result = await ctx.runAction(component.garmin.public.completeGarminOAuth, {
|
|
998
|
-
code,
|
|
999
|
-
state,
|
|
1000
|
-
clientId,
|
|
1001
|
-
clientSecret,
|
|
1002
|
-
}) as typeof result;
|
|
1003
|
-
} catch (error) {
|
|
1004
|
-
const message =
|
|
1005
|
-
error instanceof Error ? error.message : "Unknown error";
|
|
1006
|
-
return new Response(`Garmin OAuth callback failed: ${message}`, {
|
|
1007
|
-
status: 500,
|
|
1008
|
-
});
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
if (oauth.onComplete) {
|
|
1012
|
-
try {
|
|
1013
|
-
await oauth.onComplete(ctx, {
|
|
1014
|
-
provider: "GARMIN",
|
|
1015
|
-
userId: result.userId,
|
|
1016
|
-
connectionId: result.connectionId,
|
|
1017
|
-
});
|
|
1018
|
-
} catch (callbackError) {
|
|
1019
|
-
console.error(
|
|
1020
|
-
"[soma] garmin oauth.onComplete callback error:",
|
|
1021
|
-
callbackError instanceof Error ? callbackError.message : callbackError,
|
|
1022
|
-
);
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
if (oauth.redirectTo) {
|
|
1027
|
-
return new Response(null, {
|
|
1028
|
-
status: 302,
|
|
1029
|
-
headers: { Location: oauth.redirectTo },
|
|
1030
|
-
});
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
return new Response("Successfully connected to Garmin!", {
|
|
1034
|
-
status: 200,
|
|
1035
|
-
});
|
|
1036
|
-
}),
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
// ── Garmin Webhook Routes ──────────────────────────────────
|
|
1040
|
-
const webhookCfg = typeof garminOpts.webhook === "object" ? garminOpts.webhook : undefined;
|
|
1041
|
-
if (webhookCfg?.events) {
|
|
1042
|
-
const webhookBase = webhookCfg.basePath ?? GARMIN_WEBHOOK_BASE_PATH;
|
|
1043
|
-
|
|
1044
|
-
const webhookRoutes: Array<{
|
|
1045
|
-
suffix: string;
|
|
1046
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1047
|
-
action: FunctionReference<"action", "internal", { payload: any }>;
|
|
1048
|
-
}> = [
|
|
1049
|
-
// ACTIVITY category
|
|
1050
|
-
{ suffix: "/activities", action: component.garmin.webhooks.handleGarminWebhookActivities },
|
|
1051
|
-
{ suffix: "/activity-details", action: component.garmin.webhooks.handleGarminWebhookActivityDetails },
|
|
1052
|
-
{ suffix: "/manually-updated-activities", action: component.garmin.webhooks.handleGarminWebhookManuallyUpdatedActivities },
|
|
1053
|
-
{ suffix: "/move-iq", action: component.garmin.webhooks.handleGarminWebhookMoveIQ },
|
|
1054
|
-
// HEALTH category
|
|
1055
|
-
{ suffix: "/blood-pressures", action: component.garmin.webhooks.handleGarminWebhookBloodPressures },
|
|
1056
|
-
{ suffix: "/body-compositions", action: component.garmin.webhooks.handleGarminWebhookBodyCompositions },
|
|
1057
|
-
{ suffix: "/dailies", action: component.garmin.webhooks.handleGarminWebhookDailies },
|
|
1058
|
-
{ suffix: "/epochs", action: component.garmin.webhooks.handleGarminWebhookEpochs },
|
|
1059
|
-
{ suffix: "/health-snapshot", action: component.garmin.webhooks.handleGarminWebhookHealthSnapshot },
|
|
1060
|
-
{ suffix: "/sleeps", action: component.garmin.webhooks.handleGarminWebhookSleeps },
|
|
1061
|
-
{ suffix: "/hrv", action: component.garmin.webhooks.handleGarminWebhookHRVSummary },
|
|
1062
|
-
{ suffix: "/stress", action: component.garmin.webhooks.handleGarminWebhookStress },
|
|
1063
|
-
{ suffix: "/pulse-ox", action: component.garmin.webhooks.handleGarminWebhookPulseOx },
|
|
1064
|
-
{ suffix: "/respiration", action: component.garmin.webhooks.handleGarminWebhookRespiration },
|
|
1065
|
-
{ suffix: "/skin-temp", action: component.garmin.webhooks.handleGarminWebhookSkinTemp },
|
|
1066
|
-
{ suffix: "/user-metrics", action: component.garmin.webhooks.handleGarminWebhookUserMetrics },
|
|
1067
|
-
// WOMEN_HEALTH category
|
|
1068
|
-
{ suffix: "/menstrual-cycle-tracking", action: component.garmin.webhooks.handleGarminWebhookMenstrualCycleTracking },
|
|
1069
|
-
];
|
|
1070
|
-
|
|
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
|
-
|
|
1077
|
-
http.route({
|
|
1078
|
-
path: `${webhookBase}${route.suffix}`,
|
|
1079
|
-
method: "POST",
|
|
1080
|
-
handler: httpActionGeneric(async (ctx, request) => {
|
|
1081
|
-
let payload: unknown;
|
|
1082
|
-
try {
|
|
1083
|
-
payload = await request.json();
|
|
1084
|
-
} catch {
|
|
1085
|
-
return new Response("Invalid JSON body", { status: 400 });
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
let result: { processed: number; errors: Array<{ type: string; id: string; error: string }>; affectedUsers: Array<{ userId: string; connectionId: string }> } | undefined;
|
|
1089
|
-
try {
|
|
1090
|
-
result = await ctx.runAction(route.action, { payload }) as typeof result;
|
|
1091
|
-
} catch (error) {
|
|
1092
|
-
// Log but return 200 to prevent Garmin from retrying
|
|
1093
|
-
console.error(
|
|
1094
|
-
`Garmin webhook error (${route.suffix}):`,
|
|
1095
|
-
error instanceof Error ? error.message : error,
|
|
1096
|
-
);
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
if (result) {
|
|
1100
|
-
const event: GarminWebhookEvent = {
|
|
1101
|
-
dataType,
|
|
1102
|
-
processed: result.processed,
|
|
1103
|
-
errors: result.errors,
|
|
1104
|
-
affectedUsers: result.affectedUsers,
|
|
1105
|
-
};
|
|
1106
|
-
|
|
1107
|
-
const specificHandler = webhookCfg.events?.[dataType];
|
|
1108
|
-
if (typeof specificHandler === "function") {
|
|
1109
|
-
try {
|
|
1110
|
-
await specificHandler(ctx, event);
|
|
1111
|
-
} catch (callbackError) {
|
|
1112
|
-
console.error(
|
|
1113
|
-
`[soma] garmin webhook events["${dataType}"] callback error:`,
|
|
1114
|
-
callbackError instanceof Error ? callbackError.message : callbackError,
|
|
1115
|
-
);
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
|
|
1119
|
-
if (webhookCfg.onEvent) {
|
|
1120
|
-
try {
|
|
1121
|
-
await webhookCfg.onEvent(ctx, event);
|
|
1122
|
-
} catch (callbackError) {
|
|
1123
|
-
console.error(
|
|
1124
|
-
`[soma] garmin webhook onEvent callback error:`,
|
|
1125
|
-
callbackError instanceof Error ? callbackError.message : callbackError,
|
|
1126
|
-
);
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
return new Response("OK", { status: 200 });
|
|
1132
|
-
}),
|
|
1133
|
-
});
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
866
|
+
SomaGarmin.registerRoutes(http, component, opts?.garmin);
|
|
1136
867
|
}
|
|
1137
868
|
}
|
package/src/client/strava.ts
CHANGED
|
@@ -1,108 +1,199 @@
|
|
|
1
|
-
import type { SomaComponent } from "./index.js";
|
|
2
|
-
import type { ActionCtx, SomaStravaConfig } from "./types.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* @param
|
|
21
|
-
* @
|
|
22
|
-
*
|
|
23
|
-
* @
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* }
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* @
|
|
55
|
-
*
|
|
56
|
-
* @
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* },
|
|
63
|
-
* })
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* @
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* },
|
|
94
|
-
* })
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
1
|
+
import type { SomaComponent } from "./index.js";
|
|
2
|
+
import type { ActionCtx, SomaStravaConfig, RegisterRoutesOptions } from "./types.js";
|
|
3
|
+
import { httpActionGeneric, type HttpRouter } from "convex/server";
|
|
4
|
+
|
|
5
|
+
export const STRAVA_CALLBACK_PATH = "/api/strava/callback";
|
|
6
|
+
|
|
7
|
+
export class SomaStrava {
|
|
8
|
+
constructor(
|
|
9
|
+
private component: SomaComponent,
|
|
10
|
+
private requireConfig: () => SomaStravaConfig,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate a Strava OAuth authorization URL.
|
|
15
|
+
*
|
|
16
|
+
* The state parameter is stored inside the component automatically,
|
|
17
|
+
* and the callback handler registered by `registerRoutes` will
|
|
18
|
+
* complete the flow without further host-app intervention.
|
|
19
|
+
*
|
|
20
|
+
* @param ctx - Action context from the host app
|
|
21
|
+
* @param opts.userId - The host app's user identifier
|
|
22
|
+
* @param opts.redirectUri - The URL Strava will redirect to after authorization
|
|
23
|
+
* @param opts.scope - Comma-separated Strava OAuth scopes (default: "read,activity:read_all,profile:read_all")
|
|
24
|
+
* @returns `{ authUrl, state }`
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const { authUrl } = await soma.strava.getAuthUrl(ctx, {
|
|
29
|
+
* userId: "user_123",
|
|
30
|
+
* redirectUri: "https://your-app.convex.site/api/strava/callback",
|
|
31
|
+
* });
|
|
32
|
+
* // Redirect user to authUrl — the callback is handled automatically
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
async getAuthUrl(
|
|
36
|
+
ctx: ActionCtx,
|
|
37
|
+
opts: { userId: string; redirectUri: string; scope?: string },
|
|
38
|
+
) {
|
|
39
|
+
const config = this.requireConfig();
|
|
40
|
+
return await ctx.runAction(this.component.strava.public.getStravaAuthUrl, {
|
|
41
|
+
clientId: config.clientId,
|
|
42
|
+
redirectUri: opts.redirectUri,
|
|
43
|
+
scope: opts.scope,
|
|
44
|
+
userId: opts.userId,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Sync activities from Strava for an already-connected user.
|
|
50
|
+
*
|
|
51
|
+
* Automatically refreshes the access token if expired. Fetches the
|
|
52
|
+
* athlete profile and activities, transforms them, and ingests into Soma.
|
|
53
|
+
*
|
|
54
|
+
* @param ctx - Action context from the host app
|
|
55
|
+
* @param args.userId - The host app's user identifier
|
|
56
|
+
* @param args.after - Only sync activities after this Unix epoch timestamp (for incremental sync)
|
|
57
|
+
* @returns `{ synced, errors }`
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* export const syncStrava = action({
|
|
62
|
+
* args: { userId: v.string() },
|
|
63
|
+
* handler: async (ctx, { userId }) => {
|
|
64
|
+
* return await soma.strava.sync(ctx, { userId });
|
|
65
|
+
* },
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
async sync(
|
|
70
|
+
ctx: ActionCtx,
|
|
71
|
+
args: { userId: string; after?: number },
|
|
72
|
+
) {
|
|
73
|
+
const config = this.requireConfig();
|
|
74
|
+
return await ctx.runAction(this.component.strava.public.syncStrava, {
|
|
75
|
+
...args,
|
|
76
|
+
clientId: config.clientId,
|
|
77
|
+
clientSecret: config.clientSecret,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Disconnect a user from Strava.
|
|
83
|
+
*
|
|
84
|
+
* Revokes the token at Strava (best-effort), deletes stored tokens,
|
|
85
|
+
* and sets the connection to inactive.
|
|
86
|
+
*
|
|
87
|
+
* @param ctx - Action context from the host app
|
|
88
|
+
* @param args.userId - The host app's user identifier
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* export const disconnectStrava = action({
|
|
93
|
+
* args: { userId: v.string() },
|
|
94
|
+
* handler: async (ctx, { userId }) => {
|
|
95
|
+
* await soma.strava.disconnect(ctx, { userId });
|
|
96
|
+
* },
|
|
97
|
+
* });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
async disconnect(
|
|
101
|
+
ctx: ActionCtx,
|
|
102
|
+
args: { userId: string },
|
|
103
|
+
) {
|
|
104
|
+
const config = this.requireConfig();
|
|
105
|
+
return await ctx.runAction(this.component.strava.public.disconnectStrava, {
|
|
106
|
+
...args,
|
|
107
|
+
clientId: config.clientId,
|
|
108
|
+
clientSecret: config.clientSecret,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
static registerRoutes(
|
|
113
|
+
http: HttpRouter,
|
|
114
|
+
component: SomaComponent,
|
|
115
|
+
opts?: RegisterRoutesOptions["strava"],
|
|
116
|
+
) {
|
|
117
|
+
const oauth = opts?.oauth ?? {};
|
|
118
|
+
const path = oauth.path ?? STRAVA_CALLBACK_PATH;
|
|
119
|
+
|
|
120
|
+
http.route({
|
|
121
|
+
path,
|
|
122
|
+
method: "GET",
|
|
123
|
+
handler: httpActionGeneric(async (ctx, request) => {
|
|
124
|
+
const url = new URL(request.url);
|
|
125
|
+
const code = url.searchParams.get("code");
|
|
126
|
+
const state = url.searchParams.get("state");
|
|
127
|
+
|
|
128
|
+
if (!code) {
|
|
129
|
+
return new Response("Missing authorization code", { status: 400 });
|
|
130
|
+
}
|
|
131
|
+
if (!state) {
|
|
132
|
+
return new Response(
|
|
133
|
+
"Missing state parameter. Ensure the state was included " +
|
|
134
|
+
"when building the Strava auth URL via getStravaAuthUrl.",
|
|
135
|
+
{ status: 400 },
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const clientId =
|
|
140
|
+
opts?.clientId ?? process.env.STRAVA_CLIENT_ID;
|
|
141
|
+
const clientSecret =
|
|
142
|
+
opts?.clientSecret ?? process.env.STRAVA_CLIENT_SECRET;
|
|
143
|
+
|
|
144
|
+
if (!clientId || !clientSecret) {
|
|
145
|
+
return new Response(
|
|
146
|
+
"Strava credentials not configured. Set STRAVA_CLIENT_ID and " +
|
|
147
|
+
"STRAVA_CLIENT_SECRET environment variables, or pass them to registerRoutes.",
|
|
148
|
+
{ status: 500 },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let result: {
|
|
153
|
+
connectionId: string;
|
|
154
|
+
userId: string;
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
result = await ctx.runAction(component.strava.public.completeStravaOAuth, {
|
|
158
|
+
code,
|
|
159
|
+
state,
|
|
160
|
+
clientId,
|
|
161
|
+
clientSecret,
|
|
162
|
+
});
|
|
163
|
+
} catch (error) {
|
|
164
|
+
const message =
|
|
165
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
166
|
+
return new Response(`Strava OAuth callback failed: ${message}`, {
|
|
167
|
+
status: 500,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (oauth.onComplete) {
|
|
172
|
+
try {
|
|
173
|
+
await oauth.onComplete(ctx, {
|
|
174
|
+
provider: "STRAVA",
|
|
175
|
+
userId: result.userId,
|
|
176
|
+
connectionId: result.connectionId,
|
|
177
|
+
});
|
|
178
|
+
} catch (callbackError) {
|
|
179
|
+
console.error(
|
|
180
|
+
"[soma] strava onComplete callback error:",
|
|
181
|
+
callbackError instanceof Error ? callbackError.message : callbackError,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (oauth.redirectTo) {
|
|
187
|
+
return new Response(null, {
|
|
188
|
+
status: 302,
|
|
189
|
+
headers: { Location: oauth.redirectTo },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return new Response("Successfully connected to Strava!", {
|
|
194
|
+
status: 200,
|
|
195
|
+
});
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|